dig_dht/service.rs
1//! [`DhtService`] — the public handle that ties the routing table, provider store, transport, and
2//! iterative lookup into the four operations a DIG Node needs:
3//!
4//! - [`bootstrap`](DhtService::bootstrap) — seed the routing table from known peers (the dig-gossip
5//! pool / relay introducer) + populate it with a self-lookup.
6//! - [`find_providers`](DhtService::find_providers) — "who holds this content?" → the provider
7//! records (the node then fetches over the L7 peer RPC).
8//! - [`announce_provider`](DhtService::announce_provider) — "I hold this content" → PUT a provider
9//! record at the `k` nodes closest to the content key (and locally), and remember to republish it.
10//! - [`find_node`](DhtService::find_node) — the `k` peers closest to a `peer_id` (routing primitive).
11//!
12//! Plus maintenance ([`republish`](DhtService::republish), [`refresh_buckets`](DhtService::refresh_buckets),
13//! [`gc`](DhtService::gc)) and the **serving side** ([`handle_request`](DhtService::handle_request))
14//! that answers inbound DHT RPCs from other nodes.
15//!
16//! ## Serving vs. querying
17//!
18//! A node is both a client and a server of the DHT. [`handle_request`](DhtService::handle_request)
19//! is the server: given an inbound [`DhtRequest`], it reads/writes the local routing table +
20//! provider store and returns the [`DhtResponse`]. The `find_*` / `announce_*` methods are the
21//! client: they run iterative lookups over the [`DhtTransport`]. A dig-node wires `handle_request`
22//! to inbound DHT streams and gives the service a transport that dials outbound.
23
24use std::sync::Arc;
25
26use tokio::sync::Mutex;
27
28use dig_nat::PeerId;
29
30use crate::clock::now_secs;
31use crate::config::DhtConfig;
32use crate::content::ContentId;
33use crate::error::DhtError;
34use crate::key::Key;
35use crate::lookup::{iterative_find, QueryOutcome};
36use crate::provider_store::{ProviderSnapshot, ProviderStore, PutOutcome};
37use crate::record::{hex64_to_bytes, CandidateAddr, ProviderRecord};
38use crate::routing::{Contact, InsertOutcome, RoutingTable};
39use crate::transport::DhtTransport;
40use crate::wire::{DhtRequest, DhtResponse};
41
42/// A peer to bootstrap the routing table from — its `peer_id` and at least one candidate address.
43/// These come from the node's existing discovery (the dig-gossip peer pool / the relay introducer);
44/// the DHT crate takes them as input and never hard-depends on a live relay itself.
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct BootstrapPeer {
47 /// The bootstrap peer's identity.
48 pub peer_id: PeerId,
49 /// Candidate addresses to reach it.
50 pub addresses: Vec<CandidateAddr>,
51}
52
53impl BootstrapPeer {
54 /// A bootstrap peer with a single direct address.
55 pub fn direct(peer_id: PeerId, host: impl Into<String>, port: u16) -> Self {
56 BootstrapPeer {
57 peer_id,
58 addresses: vec![CandidateAddr::direct(host, port)],
59 }
60 }
61
62 fn to_contact(&self) -> Contact {
63 Contact::new(&self.peer_id, self.addresses.clone())
64 }
65}
66
67/// The DHT service for one node. Cloneable-by-`Arc` internally; wrap in `Arc` to share between the
68/// serving task (inbound RPC) and querying callers.
69pub struct DhtService {
70 local_id: PeerId,
71 /// This node's own candidate addresses — put into provider records it announces so finders can
72 /// reach it.
73 local_addresses: Vec<CandidateAddr>,
74 config: DhtConfig,
75 routing: Arc<Mutex<RoutingTable>>,
76 /// The AUTHORITATIVE provider store — records whose provider attribution this node established
77 /// (its own announces, the mTLS-checked serving-side `add_provider`, the caller-verified
78 /// [`ingest_verified_provider`](DhtService::ingest_verified_provider)). This is the store that
79 /// answers an inbound `find_providers`, so everything in it becomes THIS NODE'S CLAIM about who
80 /// holds what.
81 providers: Arc<Mutex<ProviderStore>>,
82 /// The DISCOVERY CACHE — records this node collected from its OWN lookups (SPEC §6.8). Same
83 /// type, same admission control, different trust provenance and therefore a different store:
84 /// see [`cache_discovered`](DhtService::cache_discovered) for why these two must never be one.
85 discovered: Arc<Mutex<ProviderStore>>,
86 transport: Arc<dyn DhtTransport>,
87}
88
89impl DhtService {
90 /// Create a service for the node identified by `local_id`, advertising `local_addresses` in the
91 /// provider records it announces, driving RPC over `transport`.
92 pub fn new(
93 local_id: PeerId,
94 local_addresses: Vec<CandidateAddr>,
95 config: DhtConfig,
96 transport: Arc<dyn DhtTransport>,
97 ) -> Self {
98 let routing = RoutingTable::new(&local_id, config.k);
99 let providers = ProviderStore::with_limits(config.provider_store_limits);
100 let discovered = ProviderStore::with_limits(config.discovery_cache_limits);
101 DhtService {
102 local_id,
103 local_addresses,
104 config,
105 routing: Arc::new(Mutex::new(routing)),
106 providers: Arc::new(Mutex::new(providers)),
107 discovered: Arc::new(Mutex::new(discovered)),
108 transport,
109 }
110 }
111
112 /// This node's id.
113 pub fn local_id(&self) -> &PeerId {
114 &self.local_id
115 }
116
117 /// This node's own [`Contact`] (its id + advertised addresses) — the authenticated caller
118 /// identity supplied to the transport as the RPC `from`.
119 fn local_contact(&self) -> Contact {
120 Contact::new(&self.local_id, self.local_addresses.clone())
121 }
122
123 // ---- Bootstrap ---------------------------------------------------------------------------
124
125 /// Seed the routing table from `peers` and populate it by looking up this node's own id (the
126 /// canonical Kademlia bootstrap: a self-lookup fills the buckets around us). Returns the number
127 /// of distinct peers now known.
128 ///
129 /// Safe to call repeatedly (on reconnect / when new bootstrap peers arrive) — it merges, never
130 /// resets.
131 pub async fn bootstrap(&self, peers: &[BootstrapPeer]) -> Result<usize, DhtError> {
132 {
133 let mut rt = self.routing.lock().await;
134 for p in peers {
135 let _ = rt.insert(p.to_contact());
136 }
137 }
138 // Self-lookup: find the nodes closest to us to fill our buckets.
139 let self_key = Key::from_peer_id(&self.local_id);
140 let seeds: Vec<Contact> = peers.iter().map(|p| p.to_contact()).collect();
141 let result = self.run_lookup(self_key, seeds, false).await;
142 self.absorb_contacts(&result.closest).await;
143 Ok(self.routing.lock().await.len())
144 }
145
146 /// Add a single live peer to the routing table as it connects (e.g. a `dig-gossip`
147 /// `PoolEvent::PeerAdded`), WITHOUT the network round-trip [`bootstrap`](Self::bootstrap) does.
148 ///
149 /// This is the LIVE seam the one-shot pre-connect bootstrap cannot cover: in a freshly-formed
150 /// network the pool is empty when `bootstrap` runs, so routing stays empty and `find_providers`
151 /// finds nobody. Feeding each connected peer here populates routing as the pool fills, which is
152 /// what makes cross-node discovery work (#1574). Idempotent — re-adding a known peer merges its
153 /// address(es) via the routing table's insert policy; adding this node's own id is a no-op.
154 pub async fn add_peer(&self, peer_id: &PeerId, addresses: Vec<CandidateAddr>) {
155 let contact = Contact::new(peer_id, addresses);
156 let _ = self.routing.lock().await.insert(contact);
157 }
158
159 /// Remove a peer from the routing table as it leaves (a `dig-gossip` `PoolEvent::PeerRemoved`),
160 /// keeping routing accurate so lookups don't seed from a dead contact. Returns whether it was
161 /// present. `peer_id_hex` is the 64-char hex id (as carried on `Contact::provider_peer_id` /
162 /// [`PeerId::to_hex`]).
163 pub async fn remove_peer(&self, peer_id_hex: &str) -> bool {
164 self.routing.lock().await.remove(peer_id_hex)
165 }
166
167 // ---- Client operations -------------------------------------------------------------------
168
169 /// Find the `k` peers closest to `peer_id` (the routing primitive). Runs an iterative
170 /// `find_node` lookup and returns the converged closest contacts.
171 pub async fn find_node(&self, peer_id: &PeerId) -> Result<Vec<Contact>, DhtError> {
172 let target = Key::from_peer_id(peer_id);
173 let seeds = self.seed_contacts(&target).await;
174 if seeds.is_empty() {
175 return Err(DhtError::NoPeers);
176 }
177 let result = self.run_lookup(target, seeds, false).await;
178 self.absorb_contacts(&result.closest).await;
179 Ok(result.closest)
180 }
181
182 /// Find the providers of `content` — the peers holding it. Answers from this node's
183 /// **discovery cache** when a recent lookup for the same key is still live (SPEC §6.8);
184 /// otherwise runs an iterative `find_providers` lookup toward the content key, caches what it
185 /// learns, and returns every live provider record collected (deduped by provider). The node
186 /// then connects to those providers over dig-nat and fetches via the L7 peer RPC.
187 ///
188 /// **Cached answers are what make a later direct dial free** (dig_ecosystem#3128 requirement 7):
189 /// a `.dig` fetch issues many requests against the same store, and without the cache each one
190 /// paid a fresh Kademlia walk. A live cache entry is treated as evidence that this node
191 /// completed a lookup for the key recently, so the walk is skipped entirely — records this node
192 /// holds AUTHORITATIVELY are deliberately NOT such evidence, since they may be its own announce
193 /// and short-circuiting on them would stop a publisher ever learning the other holders of its
194 /// own content.
195 ///
196 /// A cached holder is a claim by an untrusted peer, so a dial to it may fail. That costs one
197 /// failed dial, never a wrong answer — the content is accepted because it verifies against the
198 /// merkle root, never because a peer supplied it (NC-12). A caller that finds every cached
199 /// candidate undialable calls [`forget_discovered`](Self::forget_discovered) and asks again,
200 /// which re-runs the full walk.
201 ///
202 /// Returns an empty vec (not an error) when the content simply has no known providers; returns
203 /// [`DhtError::NoPeers`] only when there is no one to ask (empty routing table + no bootstrap).
204 pub async fn find_providers(
205 &self,
206 content: &ContentId,
207 ) -> Result<Vec<ProviderRecord>, DhtError> {
208 let target = content.to_key();
209 let key_hex = target.to_hex();
210
211 // Local short-circuit: if we already hold providers for this key, include them.
212 let now = now_secs();
213 let local = self.providers.lock().await.get(&key_hex, now);
214
215 let cached = self.discovered.lock().await.get(&key_hex, now);
216 if !cached.is_empty() {
217 return Ok(merge_dedup_by_provider(local, cached, now));
218 }
219
220 let seeds = self.seed_contacts(&target).await;
221 if seeds.is_empty() {
222 // No peers to ask — return whatever we hold locally (possibly empty).
223 return Ok(local);
224 }
225 let result = self.run_lookup(target, seeds, true).await;
226 self.absorb_contacts(&result.closest).await;
227
228 // Discovered records come straight off the wire from other peers' responses, bypassing
229 // `ProviderRecord::new`'s address cap — capped here before they are cached or handed back
230 // to our caller (SPEC §5.5, §14). Records for a key we did not query were already discarded
231 // at the wire boundary in `run_lookup`'s query closure (SPEC §6.7).
232 // Discovered records go to the CALLER as well as the cache, so both fields a peer controls
233 // are normalized here — the address list and the collateral pointer. Normalizing only on the
234 // way into the cache would hand the caller the raw value.
235 let mut discovered = result.providers;
236 for r in &mut discovered {
237 crate::record::sort_and_cap_addresses(&mut r.addresses);
238 r.unverified_mirror_coin_id =
239 crate::record::normalize_mirror_coin_id(r.unverified_mirror_coin_id.as_deref());
240 }
241 self.cache_discovered(&key_hex, &discovered).await;
242
243 Ok(merge_dedup_by_provider(local, discovered, now_secs()))
244 }
245
246 /// The provider records this node has CACHED for `content` from its own lookups, live as of
247 /// now — the direct-dial shortcut requirement 7 exists to provide, with no network round-trip
248 /// and no fallback walk.
249 ///
250 /// # These records MUST NOT be re-served to anyone
251 ///
252 /// They are hearsay: some peer along a lookup said that some other peer holds this content, and
253 /// nothing authenticated that claim — unlike an authoritative record, which either names the
254 /// mTLS-verified caller that announced it or was signature-checked by the caller of
255 /// [`ingest_verified_provider`](Self::ingest_verified_provider). Hearsay belongs on the FETCH
256 /// path, where a wrong candidate is merely a wasted dial because the merkle bind catches it. On
257 /// the ASSERTION path — an inbound `find_providers`, a redirect answer, anything a stranger
258 /// reads — it becomes THIS NODE'S claim about the world, and re-serving it would launder an
259 /// attacker's fabricated holder into an answer other nodes trust. This node therefore never
260 /// serves the cache (see [`handle_request_from`](Self::handle_request_from), which reads the
261 /// authoritative store only) and never publishes it (see
262 /// [`provider_snapshot`](Self::provider_snapshot)).
263 pub async fn cached_providers(&self, content: &ContentId) -> Vec<ProviderRecord> {
264 self.discovered
265 .lock()
266 .await
267 .get(&content.to_key().to_hex(), now_secs())
268 }
269
270 /// Forget every cached provider for `content`, so the next
271 /// [`find_providers`](Self::find_providers) runs a real lookup again. Returns how many cached
272 /// records were dropped.
273 ///
274 /// This is what keeps a cache miss CHEAP and keeps it from being mistaken for absence: a caller
275 /// that has tried every cached candidate and reached none of them calls this and asks again,
276 /// rather than concluding the content has no providers. It touches only this node's own cache —
277 /// never the authoritative store, so it can neither censor a key this node serves nor be
278 /// observed by any other peer.
279 pub async fn forget_discovered(&self, content: &ContentId) -> usize {
280 self.discovered
281 .lock()
282 .await
283 .remove_key(&content.to_key().to_hex())
284 }
285
286 /// Announce that THIS node holds `content`: build a provider record (this node's `peer_id` +
287 /// addresses, expiring at `now + provider_ttl`), store it locally, remember to republish it, and
288 /// PUT it at the `k` nodes closest to the content key. Returns how many peers accepted the PUT.
289 ///
290 /// Called when the node's inventory gains content (a new capsule/root/resource it now serves).
291 pub async fn announce_provider(&self, content: &ContentId) -> Result<usize, DhtError> {
292 self.announce_provider_with_collateral(content, None).await
293 }
294
295 /// As [`announce_provider`](Self::announce_provider), but also publishing this node's claimed
296 /// mirror-coin id so a verifier can fetch ONE coin instead of searching for it.
297 ///
298 /// The pointer is per-content because a mirror coin bonds a `(store, root, owner, epoch)`
299 /// tuple, and it is remembered so every [`republish`](Self::republish) re-attaches it. Pass
300 /// `None` — or call [`announce_provider`](Self::announce_provider) — when there is no coin yet;
301 /// **absence is a normal, fully-supported state**, not a degraded one, since a verifier that
302 /// cannot fetch a pointer withholds credit rather than demoting.
303 ///
304 /// To refresh the pointer across an epoch rollover, announce again with the new coin id.
305 ///
306 /// Publishing a pointer claims nothing that a consumer will believe: see
307 /// [`ProviderRecord::unverified_mirror_coin_id`].
308 pub async fn announce_provider_with_collateral(
309 &self,
310 content: &ContentId,
311 unverified_mirror_coin_id: Option<[u8; 32]>,
312 ) -> Result<usize, DhtError> {
313 let target = content.to_key();
314 let mut record = self.build_local_record(&target);
315 if let Some(coin_id) = unverified_mirror_coin_id {
316 record = record.with_unverified_mirror_coin_id(coin_id);
317 }
318
319 // Store locally + remember for republish (pointer included, so the first TTL rollover does
320 // not silently drop it).
321 {
322 let mut ps = self.providers.lock().await;
323 ps.put(record.clone());
324 ps.mark_announced_with_collateral(
325 target.to_hex(),
326 record.unverified_mirror_coin_id.clone(),
327 );
328 }
329
330 // PUT at the k closest peers we can find.
331 let seeds = self.seed_contacts(&target).await;
332 if seeds.is_empty() {
333 // No peers yet — the local record stands; republish will re-attempt once bootstrapped.
334 return Ok(0);
335 }
336 let result = self.run_lookup(target, seeds, false).await;
337 self.absorb_contacts(&result.closest).await;
338 Ok(self.put_record_at(&result.closest, &record).await)
339 }
340
341 /// Stop announcing `content` (the node no longer holds it). The record ages out of the DHT via
342 /// TTL; we just stop republishing it. Returns whether it was being announced.
343 ///
344 /// This is the **passive** withdraw: it leaves this node's own local provider record in place
345 /// (it only expires with TTL) and merely stops re-publishing it, so a `find_providers` on this
346 /// node may still return self until the local record's TTL elapses. For an **immediate**
347 /// own-retract — the local-state half of the #1423 evict+retract step — use
348 /// [`retract_own_provider`](Self::retract_own_provider).
349 pub async fn withdraw_provider(&self, content: &ContentId) -> bool {
350 let key = content.to_key().to_hex();
351 self.providers.lock().await.unmark_announced(&key)
352 }
353
354 // ---- Real-time holdings API (#1394 / #1423) ----------------------------------------------
355
356 /// Ingest a provider record for a THIRD-PARTY holder that the caller has ALREADY verified was
357 /// signed by `record.provider_peer_id` — the inbound-**add** half of the real-time holdings map
358 /// (SPEC §6.5). Returns the store admission outcome.
359 ///
360 /// This is the authenticated push path a node's announce receiver calls after verifying a
361 /// signed `HoldingsAnnounce` (dig-gossip opcode 222): the holder's signature has replaced mTLS
362 /// attribution as the proof of who provides the content, so — unlike the serving-side
363 /// `add_provider` (§6.4) — this method **bypasses the mTLS self-announce identity check** (the
364 /// caller, not the DHT, established authenticity). dig-dht itself stays crypto-free (SPEC §15):
365 /// it NEVER verifies a signature; passing an unverified record here is a caller bug that
366 /// poisons the local provider set.
367 ///
368 /// Every other admission guard still applies exactly as for `add_provider`: the address list is
369 /// capped ([`MAX_ADDRESSES_PER_RECORD`](crate::MAX_ADDRESSES_PER_RECORD)),
370 /// `unverified_mirror_coin_id` is normalized to canonical lowercase 64-hex or dropped to `None`
371 /// (so a caller need not bound it, and MUST NOT rely on it having survived verbatim),
372 /// `expires_at` is clamped to `min(record.expires_at, now + provider_ttl)` (§6.2), and the
373 /// per-key / global
374 /// admission caps (§6.3) are enforced — an over-capacity ingest returns
375 /// [`PutOutcome::RejectedOverCapacity`] and stores nothing. On acceptance the holder is folded
376 /// into the routing table so this node can reach it.
377 pub async fn ingest_verified_provider(&self, record: ProviderRecord) -> PutOutcome {
378 self.admit_verified_record(record).await
379 }
380
381 /// Remove exactly the local provider record for `(content_key, provider_peer_id)` — the
382 /// inbound-**retract** half of the real-time holdings map (SPEC §6.6). Returns whether a record
383 /// was removed.
384 ///
385 /// `content_key` and `provider_peer_id` are the 64-hex forms as they appear on a
386 /// [`ProviderRecord`] (`content` → `content.to_key().to_hex()`; the holder's `peer_id` hex).
387 /// The caller MUST have verified the retract was signed by that same `provider_peer_id`
388 /// (authenticated retract): a retract signed by one holder removes ONLY that holder's record and
389 /// can never evict another provider of the same key (censorship-resistance, §6.6). dig-dht does
390 /// not verify the signature (SPEC §15) — that is the caller's responsibility.
391 pub async fn remove_provider_record(&self, content_key: &str, provider_peer_id: &str) -> bool {
392 self.providers
393 .lock()
394 .await
395 .remove(content_key, provider_peer_id)
396 }
397
398 /// A bounded, AGGREGATED view of this node's provider store — content keys and their live
399 /// provider COUNTS, with no provider identities (dig_ecosystem #1935).
400 ///
401 /// Exposed so a node can answer the relay's RLY-009 `get_dht_records` without the caller needing
402 /// access to the store itself. Because a Kademlia node holds records for keys near its OWN
403 /// `peer_id`, this describes MANY OTHER peers' content rather than what this node caches — which
404 /// is what makes the union across nodes a usable view of the network's content layer.
405 ///
406 /// `max_keys` bounds the result; see [`ProviderStore::snapshot`] for the truncation and privacy
407 /// contract. Expired records are excluded as of the current time, so the counts agree with what
408 /// [`find_providers`](Self::find_providers) would actually return.
409 pub async fn provider_snapshot(&self, max_keys: usize) -> ProviderSnapshot {
410 self.providers.lock().await.snapshot(now_secs(), max_keys)
411 }
412
413 /// Actively retract THIS node's own provider record for `content`: remove the local record AND
414 /// stop republishing it, so `find_providers` on this node stops returning self as a holder
415 /// immediately (SPEC §6.6). Returns whether this node was providing the content (a local record
416 /// existed or the key was being announced).
417 ///
418 /// This is the local-state half of the #1423 atomic **evict + retract** step (on an LRU cache
419 /// eviction the node no longer serves the content). Unlike the passive
420 /// [`withdraw_provider`](Self::withdraw_provider) (which leaves the local record to expire via
421 /// TTL), this deletes it now. The copies previously PUT at the `k` closest peers are NOT deleted
422 /// by this call — they age out via TTL, or are removed sooner when dig-node floods the signed
423 /// retract announce and each recipient calls
424 /// [`remove_provider_record`](Self::remove_provider_record).
425 pub async fn retract_own_provider(&self, content: &ContentId) -> bool {
426 let key = content.to_key().to_hex();
427 let self_id = self.local_id.to_hex();
428 let mut ps = self.providers.lock().await;
429 let removed_record = ps.remove(&key, &self_id);
430 let was_announced = ps.unmark_announced(&key);
431 removed_record || was_announced
432 }
433
434 /// The `peer_id`s of the peers that hold `content` — a thin, address-free convenience over
435 /// [`find_providers`](Self::find_providers) for callers that only need "which peers hold X"
436 /// (e.g. an RPC holder-set query) and do not dial the holders themselves.
437 ///
438 /// `find_providers` remains the PRIMARY API: it returns full [`ProviderRecord`]s with candidate
439 /// addresses, which dig-download needs to actually connect and fetch. This method runs the same
440 /// distributed iterative lookup and simply projects each record to its holder `peer_id`
441 /// (records with a malformed peer id are skipped; the set is already deduped by provider).
442 pub async fn holders_of(&self, content: &ContentId) -> Result<Vec<PeerId>, DhtError> {
443 let records = self.find_providers(content).await?;
444 Ok(records
445 .iter()
446 .filter_map(|r| r.provider_peer_id())
447 .collect())
448 }
449
450 // ---- Maintenance -------------------------------------------------------------------------
451
452 /// Republish every content key this node still announces — re-runs the announce PUT so provider
453 /// records never expire while the node is online. Call on the [`DhtConfig::republish_interval`].
454 /// Returns the number of content keys republished.
455 pub async fn republish(&self) -> usize {
456 let keys = self.providers.lock().await.local_announcements();
457 let count = keys.len();
458 for hex in keys {
459 let Some(bytes) = hex64_to_bytes(&hex) else {
460 continue;
461 };
462 let target = Key::from_bytes(bytes);
463 let mut record = self.build_local_record(&target);
464 // Re-attach the pointer this key was announced with. Rebuilding from
465 // `build_local_record` alone would drop it on the first republish, so a node would
466 // appear to have lost its collateral pointer one TTL after announcing it.
467 record.unverified_mirror_coin_id = self
468 .providers
469 .lock()
470 .await
471 .announced_collateral(&hex)
472 .map(str::to_owned);
473 self.providers.lock().await.put(record.clone());
474 let seeds = self.seed_contacts(&target).await;
475 if !seeds.is_empty() {
476 let result = self.run_lookup(target, seeds, false).await;
477 self.absorb_contacts(&result.closest).await;
478 self.put_record_at(&result.closest, &record).await;
479 }
480 }
481 count
482 }
483
484 /// Refresh populated buckets by looking up a random key in each — keeps the routing table fresh
485 /// as peers churn. Call on the [`DhtConfig::refresh_interval`]. Returns the number of buckets
486 /// refreshed.
487 pub async fn refresh_buckets(&self) -> usize {
488 let indices = self.routing.lock().await.non_empty_bucket_indices();
489 let count = indices.len();
490 for idx in indices {
491 let target = self.random_key_in_bucket(idx);
492 let seeds = self.seed_contacts(&target).await;
493 if !seeds.is_empty() {
494 let result = self.run_lookup(target, seeds, false).await;
495 self.absorb_contacts(&result.closest).await;
496 }
497 }
498 count
499 }
500
501 /// Drop expired provider records from BOTH the authoritative store and the discovery cache
502 /// (SPEC §6.8). Call periodically (piggy-backs on republish/refresh). Returns the total number
503 /// of records removed.
504 ///
505 /// One `now` for both sweeps, so a maintenance tick cannot leave the two stores disagreeing
506 /// about which instant it ran at.
507 pub async fn gc(&self) -> usize {
508 let now = now_secs();
509 let authoritative = self.providers.lock().await.gc(now);
510 let cached = self.discovered.lock().await.gc(now);
511 authoritative + cached
512 }
513
514 /// Ping a peer for liveness; on failure, evict it from the routing table. Used by the
515 /// ping-and-replace maintenance when a bucket is full. Returns whether the peer is alive.
516 pub async fn ping(&self, peer: &Contact) -> bool {
517 let nonce = rand::random::<u64>();
518 let from = self.local_contact();
519 match self
520 .transport
521 .rpc(&from, peer, &DhtRequest::Ping { nonce })
522 .await
523 {
524 Ok(DhtResponse::Pong { nonce: got }) if got == nonce => true,
525 _ => {
526 self.routing.lock().await.remove(&peer.peer_id);
527 false
528 }
529 }
530 }
531
532 // ---- Serving side (inbound RPC) ----------------------------------------------------------
533
534 /// Answer an inbound DHT request from another node, without a known caller identity. Prefer
535 /// [`handle_request_from`](Self::handle_request_from) on an authenticated transport (it lets the
536 /// responder learn the caller and populate its routing table bidirectionally, the way Kademlia
537 /// tables fill).
538 pub async fn handle_request(&self, request: DhtRequest) -> DhtResponse {
539 self.handle_request_from(None, request).await
540 }
541
542 /// Answer an inbound DHT request, folding the **authenticated caller** into the routing table.
543 ///
544 /// This is the server half — a dig-node wires it to inbound DHT streams, passing the caller's
545 /// mTLS-verified [`Contact`] as `caller`. Learning the caller from every inbound RPC is how a
546 /// Kademlia node discovers peers *without* an explicit announce: a node that talks to you becomes
547 /// a candidate in your table. The caller MUST come from the authenticated transport (the mTLS
548 /// `peer_id`), never from the request body — identity is not self-asserted.
549 ///
550 /// It reads/writes only local state (routing table + provider store) and never makes outbound
551 /// RPCs, so it cannot recurse or block on the network.
552 pub async fn handle_request_from(
553 &self,
554 caller: Option<Contact>,
555 request: DhtRequest,
556 ) -> DhtResponse {
557 // The authenticated caller's peer_id (if any), kept for the AddProvider self-announce check
558 // below — taken BEFORE the caller Contact is (conditionally) moved into the routing table.
559 let caller_peer_id = caller.as_ref().map(|c| c.peer_id.clone());
560
561 // Learn the (authenticated) caller — every inbound RPC is evidence the caller is alive.
562 // Cap its address list at the boundary (SPEC §5.5, §14): a `Contact` decoded off the wire
563 // bypasses `Contact::new`'s cap entirely (its fields are public), so an uncapped caller
564 // address list would otherwise be folded straight into our routing table and later re-served
565 // to every peer that queries us.
566 if let Some(mut c) = caller {
567 if c.peer_id != self.local_id.to_hex() {
568 crate::record::sort_and_cap_addresses(&mut c.addresses);
569 let _ = self.routing.lock().await.insert(c);
570 }
571 }
572 match request {
573 DhtRequest::Ping { nonce } => DhtResponse::Pong { nonce },
574 DhtRequest::FindNode { target } => {
575 let Some(key) = parse_key(&target) else {
576 return DhtResponse::Error {
577 code: 2,
578 message: "bad target key".into(),
579 };
580 };
581 let nodes = self.routing.lock().await.closest(&key);
582 DhtResponse::Nodes { nodes }
583 }
584 DhtRequest::FindProviders { content_key } => {
585 let Some(key) = parse_key(&content_key) else {
586 return DhtResponse::Error {
587 code: 2,
588 message: "bad content key".into(),
589 };
590 };
591 let now = now_secs();
592 let providers = self.providers.lock().await.get(&key.to_hex(), now);
593 let closer = self.routing.lock().await.closest(&key);
594 DhtResponse::Providers { providers, closer }
595 }
596 DhtRequest::AddProvider { record } => {
597 // Self-announce check (SPEC §6.4, §14): when the caller identity is known (an
598 // authenticated transport), the record's provider_peer_id MUST be the caller itself.
599 // ProviderRecord carries no signature, so without this check any authenticated caller
600 // could announce an arbitrary THIRD-PARTY peer_id as a provider of arbitrary content
601 // at attacker-chosen addresses — provider-set poisoning. A caller we cannot identify
602 // (`handle_request`, no transport-supplied identity) cannot be checked and is let
603 // through unchanged — that path already deviates from the mTLS-authenticated model.
604 if let Some(caller_id) = &caller_peer_id {
605 if *caller_id != record.provider_peer_id {
606 return DhtResponse::Error {
607 code: 4,
608 message:
609 "add_provider: provider_peer_id must match the authenticated caller"
610 .into(),
611 };
612 }
613 }
614
615 // Address-cap, TTL-clamp, admission-control, and (on acceptance) fold into routing —
616 // the shared verified-record admission pipeline (SPEC §6.3, §14).
617 match self.admit_verified_record(record).await {
618 PutOutcome::Accepted => DhtResponse::AddProviderOk,
619 PutOutcome::RejectedOverCapacity => DhtResponse::Error {
620 code: 3,
621 message: "provider store over capacity".into(),
622 },
623 }
624 }
625 }
626 }
627
628 // ---- Internals ---------------------------------------------------------------------------
629
630 /// Admit a provider record whose provider attribution is ALREADY established — either the
631 /// serving-side mTLS self-announce check passed (`handle_request_from`'s `AddProvider` arm) or
632 /// the caller pre-verified the holder signature ([`ingest_verified_provider`]). This is the one
633 /// admission pipeline both paths share (SPEC §6.3, §14), in order:
634 ///
635 /// 1. **Cap the address list** at [`MAX_ADDRESSES_PER_RECORD`](crate::MAX_ADDRESSES_PER_RECORD)
636 /// — a record decoded off the wire bypasses `ProviderRecord::new`'s cap (its fields are
637 /// public), so an attacker could otherwise pack thousands of addresses into one record.
638 /// 2. **Normalize `unverified_mirror_coin_id`** to a canonical lowercase 64-hex string or
639 /// `None`. Same reason as the address cap and the same blind spot: the wire boundary's
640 /// `deserialize_mirror_coin_id` only runs under serde, so a record built by literal (how a
641 /// consumer folds a verified holdings-announce in) could otherwise carry a body-sized
642 /// pointer that this node stores AND re-serves until every querier's frame check rejects the
643 /// answer, making the key undiscoverable through us for a full TTL.
644 /// 3. **Clamp `expires_at`** to `now + provider_ttl` — an inbound record is never trusted to
645 /// self-report its expiry; without this a record naming `u64::MAX` would never GC.
646 /// 4. **Admission-control** via [`ProviderStore::put`], enforcing the per-key + global caps so a
647 /// flood cannot grow the store without bound.
648 /// 5. On [`PutOutcome::Accepted`], **fold the holder into the routing table** (its addresses let
649 /// us reach it). A rejected record folds nothing.
650 ///
651 /// [`ingest_verified_provider`]: Self::ingest_verified_provider
652 async fn admit_verified_record(&self, mut record: ProviderRecord) -> PutOutcome {
653 crate::record::sort_and_cap_addresses(&mut record.addresses);
654 record.unverified_mirror_coin_id =
655 crate::record::normalize_mirror_coin_id(record.unverified_mirror_coin_id.as_deref());
656
657 let now = now_secs();
658 let clamp_ceiling = now.saturating_add(self.config.provider_ttl_secs());
659 record.expires_at = record.expires_at.min(clamp_ceiling);
660
661 // `put_at` with the SAME instant the clamp used, so admission cannot reclaim a slot it
662 // considers expired while the clamp considered it live (or vice versa).
663 let outcome = self.providers.lock().await.put_at(record.clone(), now);
664 if outcome == PutOutcome::Accepted {
665 if let Some(pid) = record.provider_peer_id() {
666 let contact = Contact::new(&pid, record.addresses.clone());
667 let _ = self.routing.lock().await.insert(contact);
668 }
669 }
670 outcome
671 }
672
673 /// Cache the records a lookup for `content_key` collected, so a later fetch of the same content
674 /// can dial directly instead of walking the DHT again (SPEC §6.8, dig_ecosystem#3128 req 7).
675 ///
676 /// # Why this is a SEPARATE store from the authoritative one
677 ///
678 /// The two hold the same type and are admission-controlled by the same code, but they carry
679 /// different trust provenance, and the difference decides who may read them. An authoritative
680 /// record was attributed — the serving side checked the announcing record against its
681 /// mTLS-verified caller, or the caller of `ingest_verified_provider` checked the holder's
682 /// signature. A record collected during a lookup was attributed by NOBODY: an arbitrary peer
683 /// along the walk asserted that some third party holds the content, at addresses of its
684 /// choosing. Merging the two would make this node re-serve that assertion as its own on every
685 /// inbound `find_providers` — turning one fabricated record fed to one node into a poisoned
686 /// answer the rest of the network reads back, at a keyspace position this node has no `k`-closest
687 /// duty over. Kept apart, the worst a fabricated record achieves is a wasted dial by the one
688 /// node that cached it.
689 ///
690 /// Four admission rules, in order:
691 ///
692 /// 1. **Never cache a record naming THIS node.** It is useless as a dial target, and worse, it
693 /// would make the cache non-empty and so suppress the next real lookup — a peer that echoed
694 /// our own record back at us could pin us to a provider set of one entry we cannot use.
695 /// 2. **Never cache a record for a different key.** The wire boundary already discards those
696 /// (SPEC §6.7); re-checking costs a string compare and this write outlives the lookup that
697 /// produced it, so the invariant is asserted rather than assumed.
698 /// 3. **Normalize BOTH peer-controlled shape fields**: `unverified_mirror_coin_id` to canonical
699 /// 64-hex or `None`, and `addresses` through `sort_and_cap_addresses` (SPEC §5.5). The one
700 /// caller today, [`find_providers`](Self::find_providers), already does both in its
701 /// post-lookup pass, so this is defence in depth rather than a live fix — but that is a
702 /// property of the caller, not of this write path, and a second caller added later must
703 /// inherit the guarantee rather than be expected to remember it. A record reaching local
704 /// state holds the same shape whichever path admitted it.
705 /// 4. **Clamp the expiry DOWN to `now + discovery_cache_ttl`**, never up. A peer cannot extend
706 /// its residence in this node's cache by claiming a distant expiry, and a record that is
707 /// already expired is not cached at all.
708 ///
709 /// Every surviving record goes through [`ProviderStore::put_at`], so the discovery cache's
710 /// per-key and global caps bound it exactly as the authoritative store's bound that one — this
711 /// write path has no way to exceed them.
712 async fn cache_discovered(&self, content_key: &str, discovered: &[ProviderRecord]) {
713 let now = now_secs();
714 let ceiling = now.saturating_add(self.config.discovery_cache_ttl_secs());
715 let self_id = self.local_id.to_hex();
716
717 let mut cache = self.discovered.lock().await;
718 for record in discovered {
719 if record.provider_peer_id == self_id || record.content_key != content_key {
720 continue;
721 }
722 let mut entry = record.clone();
723 crate::record::sort_and_cap_addresses(&mut entry.addresses);
724 entry.unverified_mirror_coin_id =
725 crate::record::normalize_mirror_coin_id(entry.unverified_mirror_coin_id.as_deref());
726 entry.expires_at = entry.expires_at.min(ceiling);
727 if entry.is_expired(now) {
728 continue;
729 }
730 cache.put_at(entry, now);
731 }
732 }
733
734 /// Build a provider record for content key `target` naming THIS node, expiring at
735 /// `now + provider_ttl`.
736 fn build_local_record(&self, target: &Key) -> ProviderRecord {
737 let expires_at = now_secs().saturating_add(self.config.provider_ttl_secs());
738 ProviderRecord::new(
739 target,
740 &self.local_id,
741 self.local_addresses.clone(),
742 expires_at,
743 )
744 }
745
746 /// The seed set for a lookup toward `target`: the closest contacts we currently know.
747 async fn seed_contacts(&self, target: &Key) -> Vec<Contact> {
748 self.routing.lock().await.closest(target)
749 }
750
751 /// Run an iterative lookup toward `target` from `seeds`, querying peers over the transport. Each
752 /// peer is asked `find_providers` (which also returns closer contacts), so ONE query kind serves
753 /// both node- and provider-lookups; `stop_on_providers` controls early exit.
754 async fn run_lookup(
755 &self,
756 target: Key,
757 seeds: Vec<Contact>,
758 stop_on_providers: bool,
759 ) -> crate::lookup::LookupResult {
760 let transport = self.transport.clone();
761 let content_key = target.to_hex();
762 let from = self.local_contact();
763 let query = move |contact: Contact| {
764 let transport = transport.clone();
765 let content_key = content_key.clone();
766 let from = from.clone();
767 async move {
768 let req = DhtRequest::FindProviders {
769 content_key: content_key.clone(),
770 };
771 match transport.rpc(&from, &contact, &req).await {
772 Ok(DhtResponse::Providers {
773 mut providers,
774 closer,
775 }) => {
776 // Answer-to-question binding (SPEC §6.7, §14): keep only records for the
777 // key we actually asked about. A responder is free to say ANYTHING here —
778 // `ProviderRecord` carries no signature and the peer is not the record's
779 // subject — so without this equality check any peer on the lookup path
780 // could stamp arbitrary provider peer_ids and address hints onto records
781 // for keys the finder never queried, and the finder would return them to
782 // its caller as dial targets (dial fan-out / wasted-dial DoS, and a
783 // spirit-defeat of the #1490 amplification bound).
784 //
785 // Filtering HERE, at the wire boundary, rather than at the final merge is
786 // load-bearing: the lookup's `stop_on_providers` early exit fires as soon
787 // as any provider is collected, so a mismatched record counted as "found"
788 // would end the walk before it reached a real holder — discovery
789 // censorship. Nothing downstream of this point sees an off-key record.
790 providers.retain(|r| r.content_key == content_key);
791 Ok(QueryOutcome { closer, providers })
792 }
793 Ok(DhtResponse::Nodes { nodes }) => Ok(QueryOutcome {
794 closer: nodes,
795 providers: vec![],
796 }),
797 _ => Err(()),
798 }
799 }
800 };
801 iterative_find(
802 target,
803 seeds,
804 self.config.k,
805 self.config.alpha,
806 stop_on_providers,
807 query,
808 )
809 .await
810 }
811
812 /// Fold discovered contacts back into the routing table (skipping ourselves). Applies the LRS
813 /// insert policy; a full bucket's [`InsertOutcome::Full`] is left for the ping-and-replace
814 /// maintenance (we do not ping inline to keep lookups fast).
815 ///
816 /// `contacts` come straight off the wire (a peer's `find_node`/`find_providers` response) and
817 /// so bypass [`Contact::new`]'s address cap (its fields are public) — this is another
818 /// untrusted-input boundary (SPEC §5.5, §14), capped here before insertion.
819 async fn absorb_contacts(&self, contacts: &[Contact]) {
820 let mut rt = self.routing.lock().await;
821 for c in contacts {
822 let mut c = c.clone();
823 crate::record::sort_and_cap_addresses(&mut c.addresses);
824 match rt.insert(c) {
825 InsertOutcome::Inserted => {}
826 InsertOutcome::Full { .. } => {
827 // Bucket full — leave for ping-and-replace; do not block the lookup on a ping.
828 }
829 }
830 }
831 }
832
833 /// PUT `record` at each of `peers` via `add_provider`, counting acceptances. A peer that errors
834 /// is skipped (best-effort replication — the record survives at the peers that accepted + locally).
835 async fn put_record_at(&self, peers: &[Contact], record: &ProviderRecord) -> usize {
836 let req = DhtRequest::AddProvider {
837 record: record.clone(),
838 };
839 let from = self.local_contact();
840 let mut accepted = 0;
841 for p in peers {
842 if p.peer_id == self.local_id.to_hex() {
843 continue; // already stored locally
844 }
845 if let Ok(DhtResponse::AddProviderOk) = self.transport.rpc(&from, p, &req).await {
846 accepted += 1;
847 }
848 }
849 accepted
850 }
851
852 /// A random key whose distance from this node falls in bucket `idx` (so a refresh lookup targets
853 /// that bucket's region). Sets the bit at position `255 - idx` and randomizes the lower bits.
854 fn random_key_in_bucket(&self, idx: usize) -> Key {
855 let local = *self.local_id.as_bytes();
856 let mut distance = [0u8; 32];
857 let bit = 255 - idx; // MSB-set position for this bucket
858 let byte = bit / 8;
859 let bit_in_byte = 7 - (bit % 8);
860 distance[byte] = 1 << bit_in_byte;
861 // Randomize lower-significant bits so successive refreshes vary the target.
862 for b in distance.iter_mut().skip(byte + 1) {
863 *b = rand::random::<u8>();
864 }
865 let mut target = [0u8; 32];
866 for i in 0..32 {
867 target[i] = local[i] ^ distance[i];
868 }
869 Key::from_bytes(target)
870 }
871
872 /// The contacts currently in this node's routing table closest to `target` (diagnostic /
873 /// introspection — the peers this node knows without any network round-trip).
874 pub async fn known_closest(&self, target: &Key) -> Vec<Contact> {
875 self.routing.lock().await.closest(target)
876 }
877
878 /// The number of peers currently in this node's routing table (diagnostic / metrics).
879 pub async fn routing_len(&self) -> usize {
880 self.routing.lock().await.len()
881 }
882}
883
884/// Merge two provider sets into one answer: `authoritative` first, then `extra`, deduped by
885/// provider `peer_id` and with anything expired at `now` dropped.
886///
887/// Order is the contract, not an accident. The caller dials the list front-to-back, so the records
888/// whose provenance this node established lead, and the weaker-provenance set (a discovery-cache
889/// hit, or the records a lookup just collected) follows. A provider present in both keeps its
890/// authoritative entry, because the first occurrence wins.
891fn merge_dedup_by_provider(
892 mut authoritative: Vec<ProviderRecord>,
893 extra: Vec<ProviderRecord>,
894 now: u64,
895) -> Vec<ProviderRecord> {
896 authoritative.extend(extra);
897 let mut seen = std::collections::HashSet::new();
898 authoritative.retain(|r| !r.is_expired(now) && seen.insert(r.provider_peer_id.clone()));
899 authoritative
900}
901
902/// Parse a 64-hex string into a [`Key`] (used on the serving side for wire targets).
903fn parse_key(hex: &str) -> Option<Key> {
904 hex64_to_bytes(hex).map(Key::from_bytes)
905}
906
907#[cfg(test)]
908mod tests {
909 use super::*;
910
911 fn key_hex_round_trips() {
912 // sanity for the local hex helper
913 }
914
915 #[test]
916 fn hex64_round_trip() {
917 let bytes = [0xABu8; 32];
918 let hex = Key::from_bytes(bytes).to_hex();
919 assert_eq!(hex64_to_bytes(&hex).unwrap(), bytes);
920 assert!(hex64_to_bytes("short").is_none());
921 assert!(hex64_to_bytes(&"zz".repeat(32)).is_none());
922 key_hex_round_trips();
923 }
924
925 #[test]
926 fn parse_key_rejects_bad_hex() {
927 assert!(parse_key("nothex").is_none());
928 assert!(parse_key(&"00".repeat(32)).is_some());
929 }
930}
931
932#[cfg(test)]
933mod collateral_pointer_tests {
934 use super::*;
935 use crate::record::CandidateAddr;
936
937 const BONDED_COIN: [u8; 32] = [0x5c; 32];
938
939 /// A transport that is never dialled: these tests exercise the LOCAL provider store only, so an
940 /// unseeded routing table makes every lookup a no-op.
941 struct UnusedTransport;
942
943 #[async_trait::async_trait]
944 impl crate::transport::DhtTransport for UnusedTransport {
945 async fn rpc(
946 &self,
947 _from: &Contact,
948 _peer: &Contact,
949 _request: &DhtRequest,
950 ) -> Result<DhtResponse, DhtError> {
951 unreachable!("collateral-pointer tests never dial a peer")
952 }
953 }
954
955 fn service() -> DhtService {
956 DhtService::new(
957 PeerId::from_bytes([9u8; 32]),
958 vec![CandidateAddr::direct("h", 9444)],
959 DhtConfig::default(),
960 Arc::new(UnusedTransport),
961 )
962 }
963
964 /// The local record this node published for `content`.
965 async fn local_record(svc: &DhtService, content: &ContentId) -> ProviderRecord {
966 svc.providers
967 .lock()
968 .await
969 .get(&content.to_key().to_hex(), now_secs())
970 .into_iter()
971 .find(|r| r.provider_peer_id == svc.local_id.to_hex())
972 .expect("this node should have a local record for the announced content")
973 }
974
975 #[tokio::test]
976 async fn announcing_with_collateral_publishes_the_pointer_and_without_omits_it() {
977 let svc = service();
978 let bonded = ContentId::store([1u8; 32]);
979 let bare = ContentId::store([2u8; 32]);
980
981 svc.announce_provider_with_collateral(&bonded, Some(BONDED_COIN))
982 .await
983 .unwrap();
984 svc.announce_provider(&bare).await.unwrap();
985
986 assert_eq!(
987 local_record(&svc, &bonded)
988 .await
989 .unverified_mirror_coin_id_bytes(),
990 Some(BONDED_COIN)
991 );
992 assert_eq!(
993 local_record(&svc, &bare).await.unverified_mirror_coin_id,
994 None,
995 "a bare announce must not acquire a pointer from a sibling announce"
996 );
997 }
998
999 /// The PLACEMENT test. Republish rebuilds the record from scratch, so a pointer held anywhere
1000 /// but per-announced-key is lost on the first TTL rollover — a node would look collateralised
1001 /// for one TTL and bare afterwards.
1002 ///
1003 /// Two keys, exactly one pointered: a service-wide or config-held pointer would re-attach it to
1004 /// BOTH and pass a single-key version of this test. That is the nearest wrong implementation,
1005 /// so the bare key is the control that makes relocation observable.
1006 #[tokio::test]
1007 async fn republish_re_attaches_each_keys_own_pointer_and_only_its_own() {
1008 let svc = service();
1009 let bonded = ContentId::store([1u8; 32]);
1010 let bare = ContentId::store([2u8; 32]);
1011
1012 svc.announce_provider_with_collateral(&bonded, Some(BONDED_COIN))
1013 .await
1014 .unwrap();
1015 svc.announce_provider(&bare).await.unwrap();
1016
1017 assert_eq!(svc.republish().await, 2);
1018
1019 assert_eq!(
1020 local_record(&svc, &bonded)
1021 .await
1022 .unverified_mirror_coin_id_bytes(),
1023 Some(BONDED_COIN),
1024 "republish dropped the pointer this key was announced with"
1025 );
1026 assert_eq!(
1027 local_record(&svc, &bare).await.unverified_mirror_coin_id,
1028 None,
1029 "republish invented a pointer for a key that never had one"
1030 );
1031 }
1032
1033 /// Re-announcing after an epoch rollover replaces the pointer rather than accumulating one.
1034 #[tokio::test]
1035 async fn re_announcing_replaces_the_pointer() {
1036 let svc = service();
1037 let content = ContentId::store([1u8; 32]);
1038 let next_epoch_coin = [0xE7; 32];
1039
1040 svc.announce_provider_with_collateral(&content, Some(BONDED_COIN))
1041 .await
1042 .unwrap();
1043 svc.announce_provider_with_collateral(&content, Some(next_epoch_coin))
1044 .await
1045 .unwrap();
1046 svc.republish().await;
1047
1048 assert_eq!(
1049 local_record(&svc, &content)
1050 .await
1051 .unverified_mirror_coin_id_bytes(),
1052 Some(next_epoch_coin)
1053 );
1054 }
1055
1056 /// The NON-SERDE ingress. `ingest_verified_provider` takes an already-constructed
1057 /// [`ProviderRecord`], whose fields are all `pub`, so `deserialize_mirror_coin_id` never runs on
1058 /// it - which is exactly how a consumer folding a verified holdings-announce into the DHT builds
1059 /// one. A test that goes through serde passes without the fix and proves nothing, so this one
1060 /// builds the record by struct literal.
1061 ///
1062 /// Three pointers, because "clears the field" and "normalizes the field" are different
1063 /// implementations and only a truthful control tells them apart: one oversized (sized FROM the
1064 /// protocol's own [`MAX_FRAMED_BODY`] ceiling, which is the value that makes the record
1065 /// unservable), one 64 chars but not hex (a length-only check would admit it), and one VALID,
1066 /// which must survive.
1067 #[tokio::test]
1068 async fn ingesting_a_record_built_by_literal_normalizes_its_pointer() {
1069 use crate::wire::MAX_FRAMED_BODY;
1070
1071 let svc = service();
1072 let valid = crate::record::to_hex64(&BONDED_COIN);
1073
1074 let cases: [(&str, String, Option<String>); 3] = [
1075 (
1076 "an oversized pointer must not be stored",
1077 "a".repeat(MAX_FRAMED_BODY),
1078 None,
1079 ),
1080 (
1081 "a 64-char non-hex pointer must not be stored",
1082 "z".repeat(64),
1083 None,
1084 ),
1085 (
1086 "a canonical pointer must survive ingest",
1087 valid.clone(),
1088 Some(valid.clone()),
1089 ),
1090 ];
1091
1092 for (i, (why, pointer, expected)) in cases.into_iter().enumerate() {
1093 let content = ContentId::store([i as u8 + 40; 32]);
1094 let content_key = content.to_key().to_hex();
1095 let holder = PeerId::from_bytes([i as u8 + 70; 32]);
1096
1097 let outcome = svc
1098 .ingest_verified_provider(ProviderRecord {
1099 content_key: content_key.clone(),
1100 provider_peer_id: holder.to_hex(),
1101 addresses: vec![CandidateAddr::direct("holder.example", 9444)],
1102 expires_at: now_secs() + 60,
1103 unverified_mirror_coin_id: Some(pointer),
1104 })
1105 .await;
1106 assert_eq!(outcome, PutOutcome::Accepted, "{why}: ingest must accept");
1107
1108 let providers = svc.providers.lock().await.get(&content_key, now_secs());
1109 let stored = providers
1110 .iter()
1111 .find(|r| r.provider_peer_id == holder.to_hex())
1112 .expect("the ingested record should be stored");
1113 assert_eq!(stored.unverified_mirror_coin_id, expected, "{why}");
1114
1115 // The harm the bound exists to prevent: an oversized pointer is re-served in every
1116 // answer for this key, and no OUTBOUND cap trims it - so the frame the querier must
1117 // decode is what actually has to stay under the ceiling.
1118 let frame = crate::wire::DhtResponse::Providers {
1119 providers: providers.clone(),
1120 closer: vec![],
1121 }
1122 .encode();
1123 assert!(
1124 frame.len() <= MAX_FRAMED_BODY,
1125 "{why}: the answer for this key is unservable at {} bytes",
1126 frame.len()
1127 );
1128 }
1129 }
1130
1131 /// Withdrawing forgets the pointer with the announcement, so a later bare re-announce cannot
1132 /// resurrect a stale coin id.
1133 #[tokio::test]
1134 async fn withdrawing_forgets_the_pointer() {
1135 let svc = service();
1136 let content = ContentId::store([1u8; 32]);
1137
1138 svc.announce_provider_with_collateral(&content, Some(BONDED_COIN))
1139 .await
1140 .unwrap();
1141 svc.withdraw_provider(&content).await;
1142 svc.announce_provider(&content).await.unwrap();
1143 svc.republish().await;
1144
1145 assert_eq!(
1146 local_record(&svc, &content).await.unverified_mirror_coin_id,
1147 None
1148 );
1149 }
1150}
1151
1152#[cfg(test)]
1153mod provider_snapshot_tests {
1154 use super::*;
1155 use crate::record::CandidateAddr;
1156
1157 /// A transport that is never dialled: these tests only exercise the LOCAL provider store.
1158 struct UnusedTransport;
1159
1160 #[async_trait::async_trait]
1161 impl crate::transport::DhtTransport for UnusedTransport {
1162 async fn rpc(
1163 &self,
1164 _from: &Contact,
1165 _peer: &Contact,
1166 _request: &DhtRequest,
1167 ) -> Result<DhtResponse, DhtError> {
1168 unreachable!("provider-snapshot tests never dial a peer")
1169 }
1170 }
1171
1172 fn service() -> DhtService {
1173 DhtService::new(
1174 PeerId::from_bytes([9u8; 32]),
1175 vec![CandidateAddr::direct("h", 9444)],
1176 DhtConfig::default(),
1177 Arc::new(UnusedTransport),
1178 )
1179 }
1180
1181 async fn announce(svc: &DhtService, content_seed: u8, provider_seed: u8) {
1182 let content = ContentId::store([content_seed; 32]);
1183 svc.ingest_verified_provider(ProviderRecord::new(
1184 &content.to_key(),
1185 &PeerId::from_bytes([provider_seed; 32]),
1186 vec![CandidateAddr::direct("h", 9444)],
1187 now_secs() + 3600,
1188 ))
1189 .await;
1190 }
1191
1192 /// The accessor RLY-009 answers from: counts reachable WITHOUT handing out the store, and
1193 /// without a single provider identity crossing the boundary (dig_ecosystem #1935).
1194 #[tokio::test]
1195 async fn provider_snapshot_reports_counts_and_no_identities() {
1196 let svc = service();
1197 announce(&svc, 1, 7).await;
1198
1199 let snap = svc.provider_snapshot(100).await;
1200
1201 assert_eq!(snap.total_keys, 1);
1202 assert_eq!(snap.entries[0].providers, 1);
1203 assert!(
1204 !format!("{snap:?}").contains(&PeerId::from_bytes([7u8; 32]).to_hex()),
1205 "a provider identity must never leave the store through this accessor"
1206 );
1207 }
1208
1209 /// The bound is honoured: the store is attacker-influenced, so the answer size must be OURS.
1210 #[tokio::test]
1211 async fn provider_snapshot_honours_the_bound() {
1212 let svc = service();
1213 for i in 0..6u8 {
1214 announce(&svc, i, 100 + i).await;
1215 }
1216 let snap = svc.provider_snapshot(2).await;
1217 assert_eq!(snap.entries.len(), 2);
1218 assert!(snap.truncated);
1219 assert_eq!(snap.total_keys, 6, "the true total survives truncation");
1220 }
1221}
1222
1223/// The `CandidateAddr::host` size bound, exercised through the PUBLIC `handle_request` ingress —
1224/// the reachable one. A record arriving there is decoded into a struct whose fields are all `pub`,
1225/// so a test that only goes through a constructor proves nothing about the attacker's path.
1226#[cfg(test)]
1227mod host_size_bound_tests {
1228 use std::sync::Arc;
1229
1230 use super::*;
1231 use crate::record::{CandidateAddr, MAX_ADDRESSES_PER_RECORD, MAX_HOST_LEN};
1232 use crate::wire::MAX_FRAMED_BODY;
1233
1234 /// A transport that is never dialled: these tests only exercise local admission + the answer.
1235 struct UnusedTransport;
1236
1237 #[async_trait::async_trait]
1238 impl crate::transport::DhtTransport for UnusedTransport {
1239 async fn rpc(
1240 &self,
1241 _from: &Contact,
1242 _peer: &Contact,
1243 _request: &DhtRequest,
1244 ) -> Result<DhtResponse, DhtError> {
1245 unreachable!("host-size-bound tests never dial a peer")
1246 }
1247 }
1248
1249 fn service() -> DhtService {
1250 DhtService::new(
1251 PeerId::from_bytes([9u8; 32]),
1252 vec![CandidateAddr::direct("local.example", 9444)],
1253 DhtConfig::default(),
1254 Arc::new(UnusedTransport),
1255 )
1256 }
1257
1258 /// The control's host — an ordinary name, well under the bound, which must survive UNCHANGED.
1259 /// Without it, a fix that simply cleared every `host` would pass both assertions below while
1260 /// destroying the addresses the DHT exists to hand out.
1261 const HONEST_HOST: &str = "holder.example";
1262
1263 /// The hostile host, sized FROM the protocol's own ceiling rather than from a round number: a
1264 /// single `MAX_FRAMED_BODY`-byte host makes this key's answer exceed the frame limit on its own,
1265 /// which is precisely the harm — every querier's `decode_framed` then rejects the answer and the
1266 /// key is undiscoverable through this node until the record expires.
1267 fn hostile_host() -> String {
1268 "a".repeat(MAX_FRAMED_BODY)
1269 }
1270
1271 /// Announce `host` for `content_seed` through the public ingress, then return this node's answer
1272 /// to a `FindProviders` for that key — the exact bytes a querier would have to decode.
1273 async fn announce_then_answer(
1274 svc: &DhtService,
1275 content_seed: u8,
1276 provider_seed: u8,
1277 host: String,
1278 ) -> DhtResponse {
1279 let content = ContentId::store([content_seed; 32]);
1280 let content_key = content.to_key().to_hex();
1281
1282 let accepted = svc
1283 .handle_request(DhtRequest::AddProvider {
1284 record: ProviderRecord {
1285 content_key: content_key.clone(),
1286 provider_peer_id: PeerId::from_bytes([provider_seed; 32]).to_hex(),
1287 addresses: vec![CandidateAddr::direct(host, 9444)],
1288 expires_at: now_secs() + 3600,
1289 unverified_mirror_coin_id: None,
1290 },
1291 })
1292 .await;
1293 assert!(
1294 matches!(accepted, DhtResponse::AddProviderOk),
1295 "the announce must be ACCEPTED — the bound normalizes the record, it does not reject it"
1296 );
1297
1298 svc.handle_request(DhtRequest::FindProviders { content_key })
1299 .await
1300 }
1301
1302 /// `cache_discovered`'s OWN pointer normalization, called directly.
1303 ///
1304 /// The end-to-end swarm test for this exercises `find_providers`, which normalizes the record
1305 /// before handing it here — so that test passes with or without this line and cannot speak for
1306 /// it. This one calls the private write path directly, which is the only way to show the layer
1307 /// is real rather than carried by its single current caller. That is the whole point of the
1308 /// line: a second caller added later inherits the guarantee.
1309 #[tokio::test]
1310 async fn the_discovery_cache_normalizes_its_own_pointer() {
1311 let svc = service();
1312 let content = ContentId::store([0xC1; 32]);
1313 let content_key = content.to_key().to_hex();
1314
1315 svc.cache_discovered(
1316 &content_key,
1317 &[ProviderRecord {
1318 content_key: content_key.clone(),
1319 provider_peer_id: PeerId::from_bytes([0x71; 32]).to_hex(),
1320 addresses: vec![CandidateAddr::direct(HONEST_HOST, 9444)],
1321 expires_at: now_secs() + 60,
1322 unverified_mirror_coin_id: Some(hostile_host()),
1323 }],
1324 )
1325 .await;
1326
1327 let cached = svc.cached_providers(&content).await;
1328 assert_eq!(cached.len(), 1, "the record should have been cached");
1329 assert_eq!(
1330 cached[0].unverified_mirror_coin_id, None,
1331 "the cache must normalize the pointer itself, not rely on its caller having done it"
1332 );
1333 }
1334
1335 /// `cache_discovered`'s OWN address cap, called directly — the sibling of the pointer test
1336 /// above, and blind in the same way for the same reason.
1337 ///
1338 /// Every end-to-end route into this write path runs through `find_providers`, which caps the
1339 /// addresses before handing them here, so no swarm-level assertion can distinguish "the cache
1340 /// caps" from "its one caller capped first". Calling the private write path directly is the
1341 /// only fixture that can, and SPEC §6.8 admission rule 3 states the cap as a MUST **at the cache
1342 /// write itself** — a normative claim that needs a test standing on that line alone.
1343 ///
1344 /// Both halves of the cap are exercised, because "drops the unrepresentable" and "bounds the
1345 /// count" are different implementations: an over-long host must not be cached, an honest one
1346 /// beside it must survive verbatim (a clear-everything fix fails that), and a list over
1347 /// `MAX_ADDRESSES_PER_RECORD` must come back at the cap.
1348 #[tokio::test]
1349 async fn the_discovery_cache_caps_its_own_addresses() {
1350 let svc = service();
1351 let content = ContentId::store([0xC2; 32]);
1352 let content_key = content.to_key().to_hex();
1353
1354 // One unrepresentable host, one honest control, then enough filler to exceed the count cap.
1355 let mut addresses = vec![
1356 CandidateAddr::direct(hostile_host(), 9444),
1357 CandidateAddr::direct(HONEST_HOST, 9444),
1358 ];
1359 for i in 0..=MAX_ADDRESSES_PER_RECORD {
1360 addresses.push(CandidateAddr::direct(format!("filler-{i}.example"), 9444));
1361 }
1362
1363 svc.cache_discovered(
1364 &content_key,
1365 &[ProviderRecord {
1366 content_key: content_key.clone(),
1367 provider_peer_id: PeerId::from_bytes([0x72; 32]).to_hex(),
1368 addresses,
1369 expires_at: now_secs() + 60,
1370 unverified_mirror_coin_id: None,
1371 }],
1372 )
1373 .await;
1374
1375 let cached = svc.cached_providers(&content).await;
1376 assert_eq!(cached.len(), 1, "the record should have been cached");
1377 let hosts: Vec<String> = cached[0].addresses.iter().map(|a| a.host.clone()).collect();
1378
1379 assert!(
1380 hosts.iter().all(|h| h.len() <= MAX_HOST_LEN),
1381 "the cache must drop an unrepresentable host itself, not rely on its caller having done it"
1382 );
1383 assert!(
1384 hosts.iter().any(|h| h == HONEST_HOST),
1385 "the cap must drop only what it cannot represent — an ordinary host survives verbatim"
1386 );
1387 assert_eq!(
1388 cached[0].addresses.len(),
1389 MAX_ADDRESSES_PER_RECORD,
1390 "the cache must bound the address COUNT itself as well as each entry's size"
1391 );
1392 }
1393
1394 fn stored_hosts(answer: &DhtResponse) -> Vec<String> {
1395 match answer {
1396 DhtResponse::Providers { providers, .. } => providers
1397 .iter()
1398 .flat_map(|r| r.addresses.iter())
1399 .map(|a| a.host.clone())
1400 .collect(),
1401 other => panic!("expected a Providers answer, got {other:?}"),
1402 }
1403 }
1404
1405 /// ASSERTION 1 — the oversized host does not survive admission, while an honest one does.
1406 ///
1407 /// Deliberately separate from the frame-size assertion below: the two are not carried by one
1408 /// another, and keeping them apart is what proves it. This one can be satisfied by a bound
1409 /// placed anywhere on the write path; the frame assertion names the actual harm.
1410 #[tokio::test]
1411 async fn an_oversized_host_does_not_survive_admission_and_an_honest_one_does() {
1412 let svc = service();
1413
1414 let hostile = announce_then_answer(&svc, 1, 0x41, hostile_host()).await;
1415 assert!(
1416 stored_hosts(&hostile)
1417 .iter()
1418 .all(|h| h.len() <= MAX_HOST_LEN),
1419 "an over-long host was stored and re-served"
1420 );
1421
1422 let honest = announce_then_answer(&svc, 2, 0x42, HONEST_HOST.to_string()).await;
1423 assert_eq!(
1424 stored_hosts(&honest),
1425 vec![HONEST_HOST.to_string()],
1426 "the bound must drop only what it cannot represent — an ordinary host survives verbatim"
1427 );
1428 }
1429
1430 /// ASSERTION 2 — the answer this node serves for the attacked key stays inside the protocol's
1431 /// frame ceiling, so it remains decodable by every querier.
1432 ///
1433 /// This is the assertion that names the harm, and the one a future refactor is least likely to
1434 /// break by accident. It is checked on a service that has ALSO admitted an honest record, so the
1435 /// `closer` list the poisoned contact bloats is genuinely populated.
1436 #[tokio::test]
1437 async fn the_answer_for_an_attacked_key_stays_within_the_frame_ceiling() {
1438 let svc = service();
1439
1440 announce_then_answer(&svc, 2, 0x42, HONEST_HOST.to_string()).await;
1441 let answer = announce_then_answer(&svc, 1, 0x41, hostile_host()).await;
1442
1443 let frame = answer.encode();
1444 assert!(
1445 frame.len() <= MAX_FRAMED_BODY,
1446 "the answer for this key is unservable at {} bytes (ceiling {MAX_FRAMED_BODY})",
1447 frame.len()
1448 );
1449 }
1450}