Skip to main content

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::{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    providers: Arc<Mutex<ProviderStore>>,
77    transport: Arc<dyn DhtTransport>,
78}
79
80impl DhtService {
81    /// Create a service for the node identified by `local_id`, advertising `local_addresses` in the
82    /// provider records it announces, driving RPC over `transport`.
83    pub fn new(
84        local_id: PeerId,
85        local_addresses: Vec<CandidateAddr>,
86        config: DhtConfig,
87        transport: Arc<dyn DhtTransport>,
88    ) -> Self {
89        let routing = RoutingTable::new(&local_id, config.k);
90        let providers = ProviderStore::with_limits(config.provider_store_limits);
91        DhtService {
92            local_id,
93            local_addresses,
94            config,
95            routing: Arc::new(Mutex::new(routing)),
96            providers: Arc::new(Mutex::new(providers)),
97            transport,
98        }
99    }
100
101    /// This node's id.
102    pub fn local_id(&self) -> &PeerId {
103        &self.local_id
104    }
105
106    /// This node's own [`Contact`] (its id + advertised addresses) — the authenticated caller
107    /// identity supplied to the transport as the RPC `from`.
108    fn local_contact(&self) -> Contact {
109        Contact::new(&self.local_id, self.local_addresses.clone())
110    }
111
112    // ---- Bootstrap ---------------------------------------------------------------------------
113
114    /// Seed the routing table from `peers` and populate it by looking up this node's own id (the
115    /// canonical Kademlia bootstrap: a self-lookup fills the buckets around us). Returns the number
116    /// of distinct peers now known.
117    ///
118    /// Safe to call repeatedly (on reconnect / when new bootstrap peers arrive) — it merges, never
119    /// resets.
120    pub async fn bootstrap(&self, peers: &[BootstrapPeer]) -> Result<usize, DhtError> {
121        {
122            let mut rt = self.routing.lock().await;
123            for p in peers {
124                let _ = rt.insert(p.to_contact());
125            }
126        }
127        // Self-lookup: find the nodes closest to us to fill our buckets.
128        let self_key = Key::from_peer_id(&self.local_id);
129        let seeds: Vec<Contact> = peers.iter().map(|p| p.to_contact()).collect();
130        let result = self.run_lookup(self_key, seeds, false).await;
131        self.absorb_contacts(&result.closest).await;
132        Ok(self.routing.lock().await.len())
133    }
134
135    /// Add a single live peer to the routing table as it connects (e.g. a `dig-gossip`
136    /// `PoolEvent::PeerAdded`), WITHOUT the network round-trip [`bootstrap`](Self::bootstrap) does.
137    ///
138    /// This is the LIVE seam the one-shot pre-connect bootstrap cannot cover: in a freshly-formed
139    /// network the pool is empty when `bootstrap` runs, so routing stays empty and `find_providers`
140    /// finds nobody. Feeding each connected peer here populates routing as the pool fills, which is
141    /// what makes cross-node discovery work (#1574). Idempotent — re-adding a known peer merges its
142    /// address(es) via the routing table's insert policy; adding this node's own id is a no-op.
143    pub async fn add_peer(&self, peer_id: &PeerId, addresses: Vec<CandidateAddr>) {
144        let contact = Contact::new(peer_id, addresses);
145        let _ = self.routing.lock().await.insert(contact);
146    }
147
148    /// Remove a peer from the routing table as it leaves (a `dig-gossip` `PoolEvent::PeerRemoved`),
149    /// keeping routing accurate so lookups don't seed from a dead contact. Returns whether it was
150    /// present. `peer_id_hex` is the 64-char hex id (as carried on `Contact::provider_peer_id` /
151    /// [`PeerId::to_hex`]).
152    pub async fn remove_peer(&self, peer_id_hex: &str) -> bool {
153        self.routing.lock().await.remove(peer_id_hex)
154    }
155
156    // ---- Client operations -------------------------------------------------------------------
157
158    /// Find the `k` peers closest to `peer_id` (the routing primitive). Runs an iterative
159    /// `find_node` lookup and returns the converged closest contacts.
160    pub async fn find_node(&self, peer_id: &PeerId) -> Result<Vec<Contact>, DhtError> {
161        let target = Key::from_peer_id(peer_id);
162        let seeds = self.seed_contacts(&target).await;
163        if seeds.is_empty() {
164            return Err(DhtError::NoPeers);
165        }
166        let result = self.run_lookup(target, seeds, false).await;
167        self.absorb_contacts(&result.closest).await;
168        Ok(result.closest)
169    }
170
171    /// Find the providers of `content` — the peers holding it. Runs an iterative `find_providers`
172    /// lookup toward the content key, returning every live provider record collected (deduped by
173    /// provider). The node then connects to those providers over dig-nat and fetches via the L7 peer
174    /// RPC.
175    ///
176    /// Returns an empty vec (not an error) when the content simply has no known providers; returns
177    /// [`DhtError::NoPeers`] only when there is no one to ask (empty routing table + no bootstrap).
178    pub async fn find_providers(
179        &self,
180        content: &ContentId,
181    ) -> Result<Vec<ProviderRecord>, DhtError> {
182        let target = content.to_key();
183
184        // Local short-circuit: if we already hold providers for this key, include them.
185        let now = now_secs();
186        let mut local = self.providers.lock().await.get(&target.to_hex(), now);
187
188        let seeds = self.seed_contacts(&target).await;
189        if seeds.is_empty() {
190            // No peers to ask — return whatever we hold locally (possibly empty).
191            return Ok(local);
192        }
193        let result = self.run_lookup(target, seeds, true).await;
194        self.absorb_contacts(&result.closest).await;
195
196        // Merge local + discovered, dedup by provider, drop expired. Discovered records come
197        // straight off the wire from other peers' responses, bypassing `ProviderRecord::new`'s
198        // address cap — capped here before handing them back to our caller (SPEC §5.5, §14).
199        // Records for a key we did not query were already discarded at the wire boundary in
200        // `run_lookup`'s query closure (SPEC §6.7), so every record here is for `target`.
201        let mut discovered = result.providers;
202        for r in &mut discovered {
203            crate::record::sort_and_cap_addresses(&mut r.addresses);
204        }
205        local.extend(discovered);
206        let now = now_secs();
207        let mut seen = std::collections::HashSet::new();
208        local.retain(|r| !r.is_expired(now) && seen.insert(r.provider_peer_id.clone()));
209        Ok(local)
210    }
211
212    /// Announce that THIS node holds `content`: build a provider record (this node's `peer_id` +
213    /// addresses, expiring at `now + provider_ttl`), store it locally, remember to republish it, and
214    /// PUT it at the `k` nodes closest to the content key. Returns how many peers accepted the PUT.
215    ///
216    /// Called when the node's inventory gains content (a new capsule/root/resource it now serves).
217    pub async fn announce_provider(&self, content: &ContentId) -> Result<usize, DhtError> {
218        let target = content.to_key();
219        let record = self.build_local_record(&target);
220
221        // Store locally + remember for republish.
222        {
223            let mut ps = self.providers.lock().await;
224            ps.put(record.clone());
225            ps.mark_announced(target.to_hex());
226        }
227
228        // PUT at the k closest peers we can find.
229        let seeds = self.seed_contacts(&target).await;
230        if seeds.is_empty() {
231            // No peers yet — the local record stands; republish will re-attempt once bootstrapped.
232            return Ok(0);
233        }
234        let result = self.run_lookup(target, seeds, false).await;
235        self.absorb_contacts(&result.closest).await;
236        Ok(self.put_record_at(&result.closest, &record).await)
237    }
238
239    /// Stop announcing `content` (the node no longer holds it). The record ages out of the DHT via
240    /// TTL; we just stop republishing it. Returns whether it was being announced.
241    ///
242    /// This is the **passive** withdraw: it leaves this node's own local provider record in place
243    /// (it only expires with TTL) and merely stops re-publishing it, so a `find_providers` on this
244    /// node may still return self until the local record's TTL elapses. For an **immediate**
245    /// own-retract — the local-state half of the #1423 evict+retract step — use
246    /// [`retract_own_provider`](Self::retract_own_provider).
247    pub async fn withdraw_provider(&self, content: &ContentId) -> bool {
248        let key = content.to_key().to_hex();
249        self.providers.lock().await.unmark_announced(&key)
250    }
251
252    // ---- Real-time holdings API (#1394 / #1423) ----------------------------------------------
253
254    /// Ingest a provider record for a THIRD-PARTY holder that the caller has ALREADY verified was
255    /// signed by `record.provider_peer_id` — the inbound-**add** half of the real-time holdings map
256    /// (SPEC §6.5). Returns the store admission outcome.
257    ///
258    /// This is the authenticated push path a node's announce receiver calls after verifying a
259    /// signed `HoldingsAnnounce` (dig-gossip opcode 222): the holder's signature has replaced mTLS
260    /// attribution as the proof of who provides the content, so — unlike the serving-side
261    /// `add_provider` (§6.4) — this method **bypasses the mTLS self-announce identity check** (the
262    /// caller, not the DHT, established authenticity). dig-dht itself stays crypto-free (SPEC §15):
263    /// it NEVER verifies a signature; passing an unverified record here is a caller bug that
264    /// poisons the local provider set.
265    ///
266    /// Every other admission guard still applies exactly as for `add_provider`: the address list is
267    /// capped ([`MAX_ADDRESSES_PER_RECORD`](crate::MAX_ADDRESSES_PER_RECORD)), `expires_at` is
268    /// clamped to `min(record.expires_at, now + provider_ttl)` (§6.2), and the per-key / global
269    /// admission caps (§6.3) are enforced — an over-capacity ingest returns
270    /// [`PutOutcome::RejectedOverCapacity`] and stores nothing. On acceptance the holder is folded
271    /// into the routing table so this node can reach it.
272    pub async fn ingest_verified_provider(&self, record: ProviderRecord) -> PutOutcome {
273        self.admit_verified_record(record).await
274    }
275
276    /// Remove exactly the local provider record for `(content_key, provider_peer_id)` — the
277    /// inbound-**retract** half of the real-time holdings map (SPEC §6.6). Returns whether a record
278    /// was removed.
279    ///
280    /// `content_key` and `provider_peer_id` are the 64-hex forms as they appear on a
281    /// [`ProviderRecord`] (`content` → `content.to_key().to_hex()`; the holder's `peer_id` hex).
282    /// The caller MUST have verified the retract was signed by that same `provider_peer_id`
283    /// (authenticated retract): a retract signed by one holder removes ONLY that holder's record and
284    /// can never evict another provider of the same key (censorship-resistance, §6.6). dig-dht does
285    /// not verify the signature (SPEC §15) — that is the caller's responsibility.
286    pub async fn remove_provider_record(&self, content_key: &str, provider_peer_id: &str) -> bool {
287        self.providers
288            .lock()
289            .await
290            .remove(content_key, provider_peer_id)
291    }
292
293    /// A bounded, AGGREGATED view of this node's provider store — content keys and their live
294    /// provider COUNTS, with no provider identities (dig_ecosystem #1935).
295    ///
296    /// Exposed so a node can answer the relay's RLY-009 `get_dht_records` without the caller needing
297    /// access to the store itself. Because a Kademlia node holds records for keys near its OWN
298    /// `peer_id`, this describes MANY OTHER peers' content rather than what this node caches — which
299    /// is what makes the union across nodes a usable view of the network's content layer.
300    ///
301    /// `max_keys` bounds the result; see [`ProviderStore::snapshot`] for the truncation and privacy
302    /// contract. Expired records are excluded as of the current time, so the counts agree with what
303    /// [`find_providers`](Self::find_providers) would actually return.
304    pub async fn provider_snapshot(&self, max_keys: usize) -> ProviderSnapshot {
305        self.providers.lock().await.snapshot(now_secs(), max_keys)
306    }
307
308    /// Actively retract THIS node's own provider record for `content`: remove the local record AND
309    /// stop republishing it, so `find_providers` on this node stops returning self as a holder
310    /// immediately (SPEC §6.6). Returns whether this node was providing the content (a local record
311    /// existed or the key was being announced).
312    ///
313    /// This is the local-state half of the #1423 atomic **evict + retract** step (on an LRU cache
314    /// eviction the node no longer serves the content). Unlike the passive
315    /// [`withdraw_provider`](Self::withdraw_provider) (which leaves the local record to expire via
316    /// TTL), this deletes it now. The copies previously PUT at the `k` closest peers are NOT deleted
317    /// by this call — they age out via TTL, or are removed sooner when dig-node floods the signed
318    /// retract announce and each recipient calls
319    /// [`remove_provider_record`](Self::remove_provider_record).
320    pub async fn retract_own_provider(&self, content: &ContentId) -> bool {
321        let key = content.to_key().to_hex();
322        let self_id = self.local_id.to_hex();
323        let mut ps = self.providers.lock().await;
324        let removed_record = ps.remove(&key, &self_id);
325        let was_announced = ps.unmark_announced(&key);
326        removed_record || was_announced
327    }
328
329    /// The `peer_id`s of the peers that hold `content` — a thin, address-free convenience over
330    /// [`find_providers`](Self::find_providers) for callers that only need "which peers hold X"
331    /// (e.g. an RPC holder-set query) and do not dial the holders themselves.
332    ///
333    /// `find_providers` remains the PRIMARY API: it returns full [`ProviderRecord`]s with candidate
334    /// addresses, which dig-download needs to actually connect and fetch. This method runs the same
335    /// distributed iterative lookup and simply projects each record to its holder `peer_id`
336    /// (records with a malformed peer id are skipped; the set is already deduped by provider).
337    pub async fn holders_of(&self, content: &ContentId) -> Result<Vec<PeerId>, DhtError> {
338        let records = self.find_providers(content).await?;
339        Ok(records
340            .iter()
341            .filter_map(|r| r.provider_peer_id())
342            .collect())
343    }
344
345    // ---- Maintenance -------------------------------------------------------------------------
346
347    /// Republish every content key this node still announces — re-runs the announce PUT so provider
348    /// records never expire while the node is online. Call on the [`DhtConfig::republish_interval`].
349    /// Returns the number of content keys republished.
350    pub async fn republish(&self) -> usize {
351        let keys = self.providers.lock().await.local_announcements();
352        let count = keys.len();
353        for hex in keys {
354            let Some(bytes) = hex64_to_bytes(&hex) else {
355                continue;
356            };
357            let target = Key::from_bytes(bytes);
358            let record = self.build_local_record(&target);
359            self.providers.lock().await.put(record.clone());
360            let seeds = self.seed_contacts(&target).await;
361            if !seeds.is_empty() {
362                let result = self.run_lookup(target, seeds, false).await;
363                self.absorb_contacts(&result.closest).await;
364                self.put_record_at(&result.closest, &record).await;
365            }
366        }
367        count
368    }
369
370    /// Refresh populated buckets by looking up a random key in each — keeps the routing table fresh
371    /// as peers churn. Call on the [`DhtConfig::refresh_interval`]. Returns the number of buckets
372    /// refreshed.
373    pub async fn refresh_buckets(&self) -> usize {
374        let indices = self.routing.lock().await.non_empty_bucket_indices();
375        let count = indices.len();
376        for idx in indices {
377            let target = self.random_key_in_bucket(idx);
378            let seeds = self.seed_contacts(&target).await;
379            if !seeds.is_empty() {
380                let result = self.run_lookup(target, seeds, false).await;
381                self.absorb_contacts(&result.closest).await;
382            }
383        }
384        count
385    }
386
387    /// Drop expired provider records. Call periodically (piggy-backs on republish/refresh). Returns
388    /// the number of records removed.
389    pub async fn gc(&self) -> usize {
390        self.providers.lock().await.gc(now_secs())
391    }
392
393    /// Ping a peer for liveness; on failure, evict it from the routing table. Used by the
394    /// ping-and-replace maintenance when a bucket is full. Returns whether the peer is alive.
395    pub async fn ping(&self, peer: &Contact) -> bool {
396        let nonce = rand::random::<u64>();
397        let from = self.local_contact();
398        match self
399            .transport
400            .rpc(&from, peer, &DhtRequest::Ping { nonce })
401            .await
402        {
403            Ok(DhtResponse::Pong { nonce: got }) if got == nonce => true,
404            _ => {
405                self.routing.lock().await.remove(&peer.peer_id);
406                false
407            }
408        }
409    }
410
411    // ---- Serving side (inbound RPC) ----------------------------------------------------------
412
413    /// Answer an inbound DHT request from another node, without a known caller identity. Prefer
414    /// [`handle_request_from`](Self::handle_request_from) on an authenticated transport (it lets the
415    /// responder learn the caller and populate its routing table bidirectionally, the way Kademlia
416    /// tables fill).
417    pub async fn handle_request(&self, request: DhtRequest) -> DhtResponse {
418        self.handle_request_from(None, request).await
419    }
420
421    /// Answer an inbound DHT request, folding the **authenticated caller** into the routing table.
422    ///
423    /// This is the server half — a dig-node wires it to inbound DHT streams, passing the caller's
424    /// mTLS-verified [`Contact`] as `caller`. Learning the caller from every inbound RPC is how a
425    /// Kademlia node discovers peers *without* an explicit announce: a node that talks to you becomes
426    /// a candidate in your table. The caller MUST come from the authenticated transport (the mTLS
427    /// `peer_id`), never from the request body — identity is not self-asserted.
428    ///
429    /// It reads/writes only local state (routing table + provider store) and never makes outbound
430    /// RPCs, so it cannot recurse or block on the network.
431    pub async fn handle_request_from(
432        &self,
433        caller: Option<Contact>,
434        request: DhtRequest,
435    ) -> DhtResponse {
436        // The authenticated caller's peer_id (if any), kept for the AddProvider self-announce check
437        // below — taken BEFORE the caller Contact is (conditionally) moved into the routing table.
438        let caller_peer_id = caller.as_ref().map(|c| c.peer_id.clone());
439
440        // Learn the (authenticated) caller — every inbound RPC is evidence the caller is alive.
441        // Cap its address list at the boundary (SPEC §5.5, §14): a `Contact` decoded off the wire
442        // bypasses `Contact::new`'s cap entirely (its fields are public), so an uncapped caller
443        // address list would otherwise be folded straight into our routing table and later re-served
444        // to every peer that queries us.
445        if let Some(mut c) = caller {
446            if c.peer_id != self.local_id.to_hex() {
447                crate::record::sort_and_cap_addresses(&mut c.addresses);
448                let _ = self.routing.lock().await.insert(c);
449            }
450        }
451        match request {
452            DhtRequest::Ping { nonce } => DhtResponse::Pong { nonce },
453            DhtRequest::FindNode { target } => {
454                let Some(key) = parse_key(&target) else {
455                    return DhtResponse::Error {
456                        code: 2,
457                        message: "bad target key".into(),
458                    };
459                };
460                let nodes = self.routing.lock().await.closest(&key);
461                DhtResponse::Nodes { nodes }
462            }
463            DhtRequest::FindProviders { content_key } => {
464                let Some(key) = parse_key(&content_key) else {
465                    return DhtResponse::Error {
466                        code: 2,
467                        message: "bad content key".into(),
468                    };
469                };
470                let now = now_secs();
471                let providers = self.providers.lock().await.get(&key.to_hex(), now);
472                let closer = self.routing.lock().await.closest(&key);
473                DhtResponse::Providers { providers, closer }
474            }
475            DhtRequest::AddProvider { record } => {
476                // Self-announce check (SPEC §6.4, §14): when the caller identity is known (an
477                // authenticated transport), the record's provider_peer_id MUST be the caller itself.
478                // ProviderRecord carries no signature, so without this check any authenticated caller
479                // could announce an arbitrary THIRD-PARTY peer_id as a provider of arbitrary content
480                // at attacker-chosen addresses — provider-set poisoning. A caller we cannot identify
481                // (`handle_request`, no transport-supplied identity) cannot be checked and is let
482                // through unchanged — that path already deviates from the mTLS-authenticated model.
483                if let Some(caller_id) = &caller_peer_id {
484                    if *caller_id != record.provider_peer_id {
485                        return DhtResponse::Error {
486                            code: 4,
487                            message:
488                                "add_provider: provider_peer_id must match the authenticated caller"
489                                    .into(),
490                        };
491                    }
492                }
493
494                // Address-cap, TTL-clamp, admission-control, and (on acceptance) fold into routing —
495                // the shared verified-record admission pipeline (SPEC §6.3, §14).
496                match self.admit_verified_record(record).await {
497                    PutOutcome::Accepted => DhtResponse::AddProviderOk,
498                    PutOutcome::RejectedOverCapacity => DhtResponse::Error {
499                        code: 3,
500                        message: "provider store over capacity".into(),
501                    },
502                }
503            }
504        }
505    }
506
507    // ---- Internals ---------------------------------------------------------------------------
508
509    /// Admit a provider record whose provider attribution is ALREADY established — either the
510    /// serving-side mTLS self-announce check passed (`handle_request_from`'s `AddProvider` arm) or
511    /// the caller pre-verified the holder signature ([`ingest_verified_provider`]). This is the one
512    /// admission pipeline both paths share (SPEC §6.3, §14), in order:
513    ///
514    /// 1. **Cap the address list** at [`MAX_ADDRESSES_PER_RECORD`](crate::MAX_ADDRESSES_PER_RECORD)
515    ///    — a record decoded off the wire bypasses `ProviderRecord::new`'s cap (its fields are
516    ///    public), so an attacker could otherwise pack thousands of addresses into one record.
517    /// 2. **Clamp `expires_at`** to `now + provider_ttl` — an inbound record is never trusted to
518    ///    self-report its expiry; without this a record naming `u64::MAX` would never GC.
519    /// 3. **Admission-control** via [`ProviderStore::put`], enforcing the per-key + global caps so a
520    ///    flood cannot grow the store without bound.
521    /// 4. On [`PutOutcome::Accepted`], **fold the holder into the routing table** (its addresses let
522    ///    us reach it). A rejected record folds nothing.
523    ///
524    /// [`ingest_verified_provider`]: Self::ingest_verified_provider
525    async fn admit_verified_record(&self, mut record: ProviderRecord) -> PutOutcome {
526        crate::record::sort_and_cap_addresses(&mut record.addresses);
527
528        let now = now_secs();
529        let clamp_ceiling = now.saturating_add(self.config.provider_ttl_secs());
530        record.expires_at = record.expires_at.min(clamp_ceiling);
531
532        // `put_at` with the SAME instant the clamp used, so admission cannot reclaim a slot it
533        // considers expired while the clamp considered it live (or vice versa).
534        let outcome = self.providers.lock().await.put_at(record.clone(), now);
535        if outcome == PutOutcome::Accepted {
536            if let Some(pid) = record.provider_peer_id() {
537                let contact = Contact::new(&pid, record.addresses.clone());
538                let _ = self.routing.lock().await.insert(contact);
539            }
540        }
541        outcome
542    }
543
544    /// Build a provider record for content key `target` naming THIS node, expiring at
545    /// `now + provider_ttl`.
546    fn build_local_record(&self, target: &Key) -> ProviderRecord {
547        let expires_at = now_secs().saturating_add(self.config.provider_ttl_secs());
548        ProviderRecord::new(
549            target,
550            &self.local_id,
551            self.local_addresses.clone(),
552            expires_at,
553        )
554    }
555
556    /// The seed set for a lookup toward `target`: the closest contacts we currently know.
557    async fn seed_contacts(&self, target: &Key) -> Vec<Contact> {
558        self.routing.lock().await.closest(target)
559    }
560
561    /// Run an iterative lookup toward `target` from `seeds`, querying peers over the transport. Each
562    /// peer is asked `find_providers` (which also returns closer contacts), so ONE query kind serves
563    /// both node- and provider-lookups; `stop_on_providers` controls early exit.
564    async fn run_lookup(
565        &self,
566        target: Key,
567        seeds: Vec<Contact>,
568        stop_on_providers: bool,
569    ) -> crate::lookup::LookupResult {
570        let transport = self.transport.clone();
571        let content_key = target.to_hex();
572        let from = self.local_contact();
573        let query = move |contact: Contact| {
574            let transport = transport.clone();
575            let content_key = content_key.clone();
576            let from = from.clone();
577            async move {
578                let req = DhtRequest::FindProviders {
579                    content_key: content_key.clone(),
580                };
581                match transport.rpc(&from, &contact, &req).await {
582                    Ok(DhtResponse::Providers {
583                        mut providers,
584                        closer,
585                    }) => {
586                        // Answer-to-question binding (SPEC §6.7, §14): keep only records for the
587                        // key we actually asked about. A responder is free to say ANYTHING here —
588                        // `ProviderRecord` carries no signature and the peer is not the record's
589                        // subject — so without this equality check any peer on the lookup path
590                        // could stamp arbitrary provider peer_ids and address hints onto records
591                        // for keys the finder never queried, and the finder would return them to
592                        // its caller as dial targets (dial fan-out / wasted-dial DoS, and a
593                        // spirit-defeat of the #1490 amplification bound).
594                        //
595                        // Filtering HERE, at the wire boundary, rather than at the final merge is
596                        // load-bearing: the lookup's `stop_on_providers` early exit fires as soon
597                        // as any provider is collected, so a mismatched record counted as "found"
598                        // would end the walk before it reached a real holder — discovery
599                        // censorship. Nothing downstream of this point sees an off-key record.
600                        providers.retain(|r| r.content_key == content_key);
601                        Ok(QueryOutcome { closer, providers })
602                    }
603                    Ok(DhtResponse::Nodes { nodes }) => Ok(QueryOutcome {
604                        closer: nodes,
605                        providers: vec![],
606                    }),
607                    _ => Err(()),
608                }
609            }
610        };
611        iterative_find(
612            target,
613            seeds,
614            self.config.k,
615            self.config.alpha,
616            stop_on_providers,
617            query,
618        )
619        .await
620    }
621
622    /// Fold discovered contacts back into the routing table (skipping ourselves). Applies the LRS
623    /// insert policy; a full bucket's [`InsertOutcome::Full`] is left for the ping-and-replace
624    /// maintenance (we do not ping inline to keep lookups fast).
625    ///
626    /// `contacts` come straight off the wire (a peer's `find_node`/`find_providers` response) and
627    /// so bypass [`Contact::new`]'s address cap (its fields are public) — this is another
628    /// untrusted-input boundary (SPEC §5.5, §14), capped here before insertion.
629    async fn absorb_contacts(&self, contacts: &[Contact]) {
630        let mut rt = self.routing.lock().await;
631        for c in contacts {
632            let mut c = c.clone();
633            crate::record::sort_and_cap_addresses(&mut c.addresses);
634            match rt.insert(c) {
635                InsertOutcome::Inserted => {}
636                InsertOutcome::Full { .. } => {
637                    // Bucket full — leave for ping-and-replace; do not block the lookup on a ping.
638                }
639            }
640        }
641    }
642
643    /// PUT `record` at each of `peers` via `add_provider`, counting acceptances. A peer that errors
644    /// is skipped (best-effort replication — the record survives at the peers that accepted + locally).
645    async fn put_record_at(&self, peers: &[Contact], record: &ProviderRecord) -> usize {
646        let req = DhtRequest::AddProvider {
647            record: record.clone(),
648        };
649        let from = self.local_contact();
650        let mut accepted = 0;
651        for p in peers {
652            if p.peer_id == self.local_id.to_hex() {
653                continue; // already stored locally
654            }
655            if let Ok(DhtResponse::AddProviderOk) = self.transport.rpc(&from, p, &req).await {
656                accepted += 1;
657            }
658        }
659        accepted
660    }
661
662    /// A random key whose distance from this node falls in bucket `idx` (so a refresh lookup targets
663    /// that bucket's region). Sets the bit at position `255 - idx` and randomizes the lower bits.
664    fn random_key_in_bucket(&self, idx: usize) -> Key {
665        let local = *self.local_id.as_bytes();
666        let mut distance = [0u8; 32];
667        let bit = 255 - idx; // MSB-set position for this bucket
668        let byte = bit / 8;
669        let bit_in_byte = 7 - (bit % 8);
670        distance[byte] = 1 << bit_in_byte;
671        // Randomize lower-significant bits so successive refreshes vary the target.
672        for b in distance.iter_mut().skip(byte + 1) {
673            *b = rand::random::<u8>();
674        }
675        let mut target = [0u8; 32];
676        for i in 0..32 {
677            target[i] = local[i] ^ distance[i];
678        }
679        Key::from_bytes(target)
680    }
681
682    /// The contacts currently in this node's routing table closest to `target` (diagnostic /
683    /// introspection — the peers this node knows without any network round-trip).
684    pub async fn known_closest(&self, target: &Key) -> Vec<Contact> {
685        self.routing.lock().await.closest(target)
686    }
687
688    /// The number of peers currently in this node's routing table (diagnostic / metrics).
689    pub async fn routing_len(&self) -> usize {
690        self.routing.lock().await.len()
691    }
692}
693
694/// Current wall-clock Unix seconds (saturating to 0 before the epoch), for provider TTLs.
695/// Parse a 64-hex string into a [`Key`] (used on the serving side for wire targets).
696fn parse_key(hex: &str) -> Option<Key> {
697    hex64_to_bytes(hex).map(Key::from_bytes)
698}
699
700/// Decode a 64-char hex string to 32 bytes.
701fn hex64_to_bytes(hex: &str) -> Option<[u8; 32]> {
702    if hex.len() != 64 {
703        return None;
704    }
705    let mut out = [0u8; 32];
706    let bytes = hex.as_bytes();
707    for (i, chunk) in bytes.chunks(2).enumerate() {
708        let hi = (chunk[0] as char).to_digit(16)?;
709        let lo = (chunk[1] as char).to_digit(16)?;
710        out[i] = ((hi << 4) | lo) as u8;
711    }
712    Some(out)
713}
714
715#[cfg(test)]
716mod tests {
717    use super::*;
718
719    fn key_hex_round_trips() {
720        // sanity for the local hex helper
721    }
722
723    #[test]
724    fn hex64_round_trip() {
725        let bytes = [0xABu8; 32];
726        let hex = Key::from_bytes(bytes).to_hex();
727        assert_eq!(hex64_to_bytes(&hex).unwrap(), bytes);
728        assert!(hex64_to_bytes("short").is_none());
729        assert!(hex64_to_bytes(&"zz".repeat(32)).is_none());
730        key_hex_round_trips();
731    }
732
733    #[test]
734    fn parse_key_rejects_bad_hex() {
735        assert!(parse_key("nothex").is_none());
736        assert!(parse_key(&"00".repeat(32)).is_some());
737    }
738}
739
740#[cfg(test)]
741mod provider_snapshot_tests {
742    use super::*;
743    use crate::record::CandidateAddr;
744
745    /// A transport that is never dialled: these tests only exercise the LOCAL provider store.
746    struct UnusedTransport;
747
748    #[async_trait::async_trait]
749    impl crate::transport::DhtTransport for UnusedTransport {
750        async fn rpc(
751            &self,
752            _from: &Contact,
753            _peer: &Contact,
754            _request: &DhtRequest,
755        ) -> Result<DhtResponse, DhtError> {
756            unreachable!("provider-snapshot tests never dial a peer")
757        }
758    }
759
760    fn service() -> DhtService {
761        DhtService::new(
762            PeerId::from_bytes([9u8; 32]),
763            vec![CandidateAddr::direct("h", 9444)],
764            DhtConfig::default(),
765            Arc::new(UnusedTransport),
766        )
767    }
768
769    async fn announce(svc: &DhtService, content_seed: u8, provider_seed: u8) {
770        let content = ContentId::store([content_seed; 32]);
771        svc.ingest_verified_provider(ProviderRecord::new(
772            &content.to_key(),
773            &PeerId::from_bytes([provider_seed; 32]),
774            vec![CandidateAddr::direct("h", 9444)],
775            now_secs() + 3600,
776        ))
777        .await;
778    }
779
780    /// The accessor RLY-009 answers from: counts reachable WITHOUT handing out the store, and
781    /// without a single provider identity crossing the boundary (dig_ecosystem #1935).
782    #[tokio::test]
783    async fn provider_snapshot_reports_counts_and_no_identities() {
784        let svc = service();
785        announce(&svc, 1, 7).await;
786
787        let snap = svc.provider_snapshot(100).await;
788
789        assert_eq!(snap.total_keys, 1);
790        assert_eq!(snap.entries[0].providers, 1);
791        assert!(
792            !format!("{snap:?}").contains(&PeerId::from_bytes([7u8; 32]).to_hex()),
793            "a provider identity must never leave the store through this accessor"
794        );
795    }
796
797    /// The bound is honoured: the store is attacker-influenced, so the answer size must be OURS.
798    #[tokio::test]
799    async fn provider_snapshot_honours_the_bound() {
800        let svc = service();
801        for i in 0..6u8 {
802            announce(&svc, i, 100 + i).await;
803        }
804        let snap = svc.provider_snapshot(2).await;
805        assert_eq!(snap.entries.len(), 2);
806        assert!(snap.truncated);
807        assert_eq!(snap.total_keys, 6, "the true total survives truncation");
808    }
809}