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;
25use std::time::{SystemTime, UNIX_EPOCH};
26
27use tokio::sync::Mutex;
28
29use dig_nat::PeerId;
30
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::{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 // ---- Client operations -------------------------------------------------------------------
136
137 /// Find the `k` peers closest to `peer_id` (the routing primitive). Runs an iterative
138 /// `find_node` lookup and returns the converged closest contacts.
139 pub async fn find_node(&self, peer_id: &PeerId) -> Result<Vec<Contact>, DhtError> {
140 let target = Key::from_peer_id(peer_id);
141 let seeds = self.seed_contacts(&target).await;
142 if seeds.is_empty() {
143 return Err(DhtError::NoPeers);
144 }
145 let result = self.run_lookup(target, seeds, false).await;
146 self.absorb_contacts(&result.closest).await;
147 Ok(result.closest)
148 }
149
150 /// Find the providers of `content` — the peers holding it. Runs an iterative `find_providers`
151 /// lookup toward the content key, returning every live provider record collected (deduped by
152 /// provider). The node then connects to those providers over dig-nat and fetches via the L7 peer
153 /// RPC.
154 ///
155 /// Returns an empty vec (not an error) when the content simply has no known providers; returns
156 /// [`DhtError::NoPeers`] only when there is no one to ask (empty routing table + no bootstrap).
157 pub async fn find_providers(
158 &self,
159 content: &ContentId,
160 ) -> Result<Vec<ProviderRecord>, DhtError> {
161 let target = content.to_key();
162
163 // Local short-circuit: if we already hold providers for this key, include them.
164 let now = now_secs();
165 let mut local = self.providers.lock().await.get(&target.to_hex(), now);
166
167 let seeds = self.seed_contacts(&target).await;
168 if seeds.is_empty() {
169 // No peers to ask — return whatever we hold locally (possibly empty).
170 return Ok(local);
171 }
172 let result = self.run_lookup(target, seeds, true).await;
173 self.absorb_contacts(&result.closest).await;
174
175 // Merge local + discovered, dedup by provider, drop expired. Discovered records come
176 // straight off the wire from other peers' responses, bypassing `ProviderRecord::new`'s
177 // address cap — capped here before handing them back to our caller (SPEC §5.5, §14).
178 let mut discovered = result.providers;
179 for r in &mut discovered {
180 crate::record::sort_and_cap_addresses(&mut r.addresses);
181 }
182 local.extend(discovered);
183 let now = now_secs();
184 let mut seen = std::collections::HashSet::new();
185 local.retain(|r| !r.is_expired(now) && seen.insert(r.provider_peer_id.clone()));
186 Ok(local)
187 }
188
189 /// Announce that THIS node holds `content`: build a provider record (this node's `peer_id` +
190 /// addresses, expiring at `now + provider_ttl`), store it locally, remember to republish it, and
191 /// PUT it at the `k` nodes closest to the content key. Returns how many peers accepted the PUT.
192 ///
193 /// Called when the node's inventory gains content (a new capsule/root/resource it now serves).
194 pub async fn announce_provider(&self, content: &ContentId) -> Result<usize, DhtError> {
195 let target = content.to_key();
196 let record = self.build_local_record(&target);
197
198 // Store locally + remember for republish.
199 {
200 let mut ps = self.providers.lock().await;
201 ps.put(record.clone());
202 ps.mark_announced(target.to_hex());
203 }
204
205 // PUT at the k closest peers we can find.
206 let seeds = self.seed_contacts(&target).await;
207 if seeds.is_empty() {
208 // No peers yet — the local record stands; republish will re-attempt once bootstrapped.
209 return Ok(0);
210 }
211 let result = self.run_lookup(target, seeds, false).await;
212 self.absorb_contacts(&result.closest).await;
213 Ok(self.put_record_at(&result.closest, &record).await)
214 }
215
216 /// Stop announcing `content` (the node no longer holds it). The record ages out of the DHT via
217 /// TTL; we just stop republishing it. Returns whether it was being announced.
218 ///
219 /// This is the **passive** withdraw: it leaves this node's own local provider record in place
220 /// (it only expires with TTL) and merely stops re-publishing it, so a `find_providers` on this
221 /// node may still return self until the local record's TTL elapses. For an **immediate**
222 /// own-retract — the local-state half of the #1423 evict+retract step — use
223 /// [`retract_own_provider`](Self::retract_own_provider).
224 pub async fn withdraw_provider(&self, content: &ContentId) -> bool {
225 let key = content.to_key().to_hex();
226 self.providers.lock().await.unmark_announced(&key)
227 }
228
229 // ---- Real-time holdings API (#1394 / #1423) ----------------------------------------------
230
231 /// Ingest a provider record for a THIRD-PARTY holder that the caller has ALREADY verified was
232 /// signed by `record.provider_peer_id` — the inbound-**add** half of the real-time holdings map
233 /// (SPEC §6.5). Returns the store admission outcome.
234 ///
235 /// This is the authenticated push path a node's announce receiver calls after verifying a
236 /// signed `HoldingsAnnounce` (dig-gossip opcode 222): the holder's signature has replaced mTLS
237 /// attribution as the proof of who provides the content, so — unlike the serving-side
238 /// `add_provider` (§6.4) — this method **bypasses the mTLS self-announce identity check** (the
239 /// caller, not the DHT, established authenticity). dig-dht itself stays crypto-free (SPEC §15):
240 /// it NEVER verifies a signature; passing an unverified record here is a caller bug that
241 /// poisons the local provider set.
242 ///
243 /// Every other admission guard still applies exactly as for `add_provider`: the address list is
244 /// capped ([`MAX_ADDRESSES_PER_RECORD`](crate::MAX_ADDRESSES_PER_RECORD)), `expires_at` is
245 /// clamped to `min(record.expires_at, now + provider_ttl)` (§6.2), and the per-key / global
246 /// admission caps (§6.3) are enforced — an over-capacity ingest returns
247 /// [`PutOutcome::RejectedOverCapacity`] and stores nothing. On acceptance the holder is folded
248 /// into the routing table so this node can reach it.
249 pub async fn ingest_verified_provider(&self, record: ProviderRecord) -> PutOutcome {
250 self.admit_verified_record(record).await
251 }
252
253 /// Remove exactly the local provider record for `(content_key, provider_peer_id)` — the
254 /// inbound-**retract** half of the real-time holdings map (SPEC §6.6). Returns whether a record
255 /// was removed.
256 ///
257 /// `content_key` and `provider_peer_id` are the 64-hex forms as they appear on a
258 /// [`ProviderRecord`] (`content` → `content.to_key().to_hex()`; the holder's `peer_id` hex).
259 /// The caller MUST have verified the retract was signed by that same `provider_peer_id`
260 /// (authenticated retract): a retract signed by one holder removes ONLY that holder's record and
261 /// can never evict another provider of the same key (censorship-resistance, §6.6). dig-dht does
262 /// not verify the signature (SPEC §15) — that is the caller's responsibility.
263 pub async fn remove_provider_record(&self, content_key: &str, provider_peer_id: &str) -> bool {
264 self.providers
265 .lock()
266 .await
267 .remove(content_key, provider_peer_id)
268 }
269
270 /// Actively retract THIS node's own provider record for `content`: remove the local record AND
271 /// stop republishing it, so `find_providers` on this node stops returning self as a holder
272 /// immediately (SPEC §6.6). Returns whether this node was providing the content (a local record
273 /// existed or the key was being announced).
274 ///
275 /// This is the local-state half of the #1423 atomic **evict + retract** step (on an LRU cache
276 /// eviction the node no longer serves the content). Unlike the passive
277 /// [`withdraw_provider`](Self::withdraw_provider) (which leaves the local record to expire via
278 /// TTL), this deletes it now. The copies previously PUT at the `k` closest peers are NOT deleted
279 /// by this call — they age out via TTL, or are removed sooner when dig-node floods the signed
280 /// retract announce and each recipient calls
281 /// [`remove_provider_record`](Self::remove_provider_record).
282 pub async fn retract_own_provider(&self, content: &ContentId) -> bool {
283 let key = content.to_key().to_hex();
284 let self_id = self.local_id.to_hex();
285 let mut ps = self.providers.lock().await;
286 let removed_record = ps.remove(&key, &self_id);
287 let was_announced = ps.unmark_announced(&key);
288 removed_record || was_announced
289 }
290
291 /// The `peer_id`s of the peers that hold `content` — a thin, address-free convenience over
292 /// [`find_providers`](Self::find_providers) for callers that only need "which peers hold X"
293 /// (e.g. an RPC holder-set query) and do not dial the holders themselves.
294 ///
295 /// `find_providers` remains the PRIMARY API: it returns full [`ProviderRecord`]s with candidate
296 /// addresses, which dig-download needs to actually connect and fetch. This method runs the same
297 /// distributed iterative lookup and simply projects each record to its holder `peer_id`
298 /// (records with a malformed peer id are skipped; the set is already deduped by provider).
299 pub async fn holders_of(&self, content: &ContentId) -> Result<Vec<PeerId>, DhtError> {
300 let records = self.find_providers(content).await?;
301 Ok(records
302 .iter()
303 .filter_map(|r| r.provider_peer_id())
304 .collect())
305 }
306
307 // ---- Maintenance -------------------------------------------------------------------------
308
309 /// Republish every content key this node still announces — re-runs the announce PUT so provider
310 /// records never expire while the node is online. Call on the [`DhtConfig::republish_interval`].
311 /// Returns the number of content keys republished.
312 pub async fn republish(&self) -> usize {
313 let keys = self.providers.lock().await.local_announcements();
314 let count = keys.len();
315 for hex in keys {
316 let Some(bytes) = hex64_to_bytes(&hex) else {
317 continue;
318 };
319 let target = Key::from_bytes(bytes);
320 let record = self.build_local_record(&target);
321 self.providers.lock().await.put(record.clone());
322 let seeds = self.seed_contacts(&target).await;
323 if !seeds.is_empty() {
324 let result = self.run_lookup(target, seeds, false).await;
325 self.absorb_contacts(&result.closest).await;
326 self.put_record_at(&result.closest, &record).await;
327 }
328 }
329 count
330 }
331
332 /// Refresh populated buckets by looking up a random key in each — keeps the routing table fresh
333 /// as peers churn. Call on the [`DhtConfig::refresh_interval`]. Returns the number of buckets
334 /// refreshed.
335 pub async fn refresh_buckets(&self) -> usize {
336 let indices = self.routing.lock().await.non_empty_bucket_indices();
337 let count = indices.len();
338 for idx in indices {
339 let target = self.random_key_in_bucket(idx);
340 let seeds = self.seed_contacts(&target).await;
341 if !seeds.is_empty() {
342 let result = self.run_lookup(target, seeds, false).await;
343 self.absorb_contacts(&result.closest).await;
344 }
345 }
346 count
347 }
348
349 /// Drop expired provider records. Call periodically (piggy-backs on republish/refresh). Returns
350 /// the number of records removed.
351 pub async fn gc(&self) -> usize {
352 self.providers.lock().await.gc(now_secs())
353 }
354
355 /// Ping a peer for liveness; on failure, evict it from the routing table. Used by the
356 /// ping-and-replace maintenance when a bucket is full. Returns whether the peer is alive.
357 pub async fn ping(&self, peer: &Contact) -> bool {
358 let nonce = rand::random::<u64>();
359 let from = self.local_contact();
360 match self
361 .transport
362 .rpc(&from, peer, &DhtRequest::Ping { nonce })
363 .await
364 {
365 Ok(DhtResponse::Pong { nonce: got }) if got == nonce => true,
366 _ => {
367 self.routing.lock().await.remove(&peer.peer_id);
368 false
369 }
370 }
371 }
372
373 // ---- Serving side (inbound RPC) ----------------------------------------------------------
374
375 /// Answer an inbound DHT request from another node, without a known caller identity. Prefer
376 /// [`handle_request_from`](Self::handle_request_from) on an authenticated transport (it lets the
377 /// responder learn the caller and populate its routing table bidirectionally, the way Kademlia
378 /// tables fill).
379 pub async fn handle_request(&self, request: DhtRequest) -> DhtResponse {
380 self.handle_request_from(None, request).await
381 }
382
383 /// Answer an inbound DHT request, folding the **authenticated caller** into the routing table.
384 ///
385 /// This is the server half — a dig-node wires it to inbound DHT streams, passing the caller's
386 /// mTLS-verified [`Contact`] as `caller`. Learning the caller from every inbound RPC is how a
387 /// Kademlia node discovers peers *without* an explicit announce: a node that talks to you becomes
388 /// a candidate in your table. The caller MUST come from the authenticated transport (the mTLS
389 /// `peer_id`), never from the request body — identity is not self-asserted.
390 ///
391 /// It reads/writes only local state (routing table + provider store) and never makes outbound
392 /// RPCs, so it cannot recurse or block on the network.
393 pub async fn handle_request_from(
394 &self,
395 caller: Option<Contact>,
396 request: DhtRequest,
397 ) -> DhtResponse {
398 // The authenticated caller's peer_id (if any), kept for the AddProvider self-announce check
399 // below — taken BEFORE the caller Contact is (conditionally) moved into the routing table.
400 let caller_peer_id = caller.as_ref().map(|c| c.peer_id.clone());
401
402 // Learn the (authenticated) caller — every inbound RPC is evidence the caller is alive.
403 // Cap its address list at the boundary (SPEC §5.5, §14): a `Contact` decoded off the wire
404 // bypasses `Contact::new`'s cap entirely (its fields are public), so an uncapped caller
405 // address list would otherwise be folded straight into our routing table and later re-served
406 // to every peer that queries us.
407 if let Some(mut c) = caller {
408 if c.peer_id != self.local_id.to_hex() {
409 crate::record::sort_and_cap_addresses(&mut c.addresses);
410 let _ = self.routing.lock().await.insert(c);
411 }
412 }
413 match request {
414 DhtRequest::Ping { nonce } => DhtResponse::Pong { nonce },
415 DhtRequest::FindNode { target } => {
416 let Some(key) = parse_key(&target) else {
417 return DhtResponse::Error {
418 code: 2,
419 message: "bad target key".into(),
420 };
421 };
422 let nodes = self.routing.lock().await.closest(&key);
423 DhtResponse::Nodes { nodes }
424 }
425 DhtRequest::FindProviders { content_key } => {
426 let Some(key) = parse_key(&content_key) else {
427 return DhtResponse::Error {
428 code: 2,
429 message: "bad content key".into(),
430 };
431 };
432 let now = now_secs();
433 let providers = self.providers.lock().await.get(&key.to_hex(), now);
434 let closer = self.routing.lock().await.closest(&key);
435 DhtResponse::Providers { providers, closer }
436 }
437 DhtRequest::AddProvider { record } => {
438 // Self-announce check (SPEC §6.4, §14): when the caller identity is known (an
439 // authenticated transport), the record's provider_peer_id MUST be the caller itself.
440 // ProviderRecord carries no signature, so without this check any authenticated caller
441 // could announce an arbitrary THIRD-PARTY peer_id as a provider of arbitrary content
442 // at attacker-chosen addresses — provider-set poisoning. A caller we cannot identify
443 // (`handle_request`, no transport-supplied identity) cannot be checked and is let
444 // through unchanged — that path already deviates from the mTLS-authenticated model.
445 if let Some(caller_id) = &caller_peer_id {
446 if *caller_id != record.provider_peer_id {
447 return DhtResponse::Error {
448 code: 4,
449 message:
450 "add_provider: provider_peer_id must match the authenticated caller"
451 .into(),
452 };
453 }
454 }
455
456 // Address-cap, TTL-clamp, admission-control, and (on acceptance) fold into routing —
457 // the shared verified-record admission pipeline (SPEC §6.3, §14).
458 match self.admit_verified_record(record).await {
459 PutOutcome::Accepted => DhtResponse::AddProviderOk,
460 PutOutcome::RejectedOverCapacity => DhtResponse::Error {
461 code: 3,
462 message: "provider store over capacity".into(),
463 },
464 }
465 }
466 }
467 }
468
469 // ---- Internals ---------------------------------------------------------------------------
470
471 /// Admit a provider record whose provider attribution is ALREADY established — either the
472 /// serving-side mTLS self-announce check passed (`handle_request_from`'s `AddProvider` arm) or
473 /// the caller pre-verified the holder signature ([`ingest_verified_provider`]). This is the one
474 /// admission pipeline both paths share (SPEC §6.3, §14), in order:
475 ///
476 /// 1. **Cap the address list** at [`MAX_ADDRESSES_PER_RECORD`](crate::MAX_ADDRESSES_PER_RECORD)
477 /// — a record decoded off the wire bypasses `ProviderRecord::new`'s cap (its fields are
478 /// public), so an attacker could otherwise pack thousands of addresses into one record.
479 /// 2. **Clamp `expires_at`** to `now + provider_ttl` — an inbound record is never trusted to
480 /// self-report its expiry; without this a record naming `u64::MAX` would never GC.
481 /// 3. **Admission-control** via [`ProviderStore::put`], enforcing the per-key + global caps so a
482 /// flood cannot grow the store without bound.
483 /// 4. On [`PutOutcome::Accepted`], **fold the holder into the routing table** (its addresses let
484 /// us reach it). A rejected record folds nothing.
485 ///
486 /// [`ingest_verified_provider`]: Self::ingest_verified_provider
487 async fn admit_verified_record(&self, mut record: ProviderRecord) -> PutOutcome {
488 crate::record::sort_and_cap_addresses(&mut record.addresses);
489
490 let clamp_ceiling = now_secs().saturating_add(self.config.provider_ttl_secs());
491 record.expires_at = record.expires_at.min(clamp_ceiling);
492
493 let outcome = self.providers.lock().await.put(record.clone());
494 if outcome == PutOutcome::Accepted {
495 if let Some(pid) = record.provider_peer_id() {
496 let contact = Contact::new(&pid, record.addresses.clone());
497 let _ = self.routing.lock().await.insert(contact);
498 }
499 }
500 outcome
501 }
502
503 /// Build a provider record for content key `target` naming THIS node, expiring at
504 /// `now + provider_ttl`.
505 fn build_local_record(&self, target: &Key) -> ProviderRecord {
506 let expires_at = now_secs().saturating_add(self.config.provider_ttl_secs());
507 ProviderRecord::new(
508 target,
509 &self.local_id,
510 self.local_addresses.clone(),
511 expires_at,
512 )
513 }
514
515 /// The seed set for a lookup toward `target`: the closest contacts we currently know.
516 async fn seed_contacts(&self, target: &Key) -> Vec<Contact> {
517 self.routing.lock().await.closest(target)
518 }
519
520 /// Run an iterative lookup toward `target` from `seeds`, querying peers over the transport. Each
521 /// peer is asked `find_providers` (which also returns closer contacts), so ONE query kind serves
522 /// both node- and provider-lookups; `stop_on_providers` controls early exit.
523 async fn run_lookup(
524 &self,
525 target: Key,
526 seeds: Vec<Contact>,
527 stop_on_providers: bool,
528 ) -> crate::lookup::LookupResult {
529 let transport = self.transport.clone();
530 let content_key = target.to_hex();
531 let from = self.local_contact();
532 let query = move |contact: Contact| {
533 let transport = transport.clone();
534 let content_key = content_key.clone();
535 let from = from.clone();
536 async move {
537 let req = DhtRequest::FindProviders { content_key };
538 match transport.rpc(&from, &contact, &req).await {
539 Ok(DhtResponse::Providers { providers, closer }) => {
540 Ok(QueryOutcome { closer, providers })
541 }
542 Ok(DhtResponse::Nodes { nodes }) => Ok(QueryOutcome {
543 closer: nodes,
544 providers: vec![],
545 }),
546 _ => Err(()),
547 }
548 }
549 };
550 iterative_find(
551 target,
552 seeds,
553 self.config.k,
554 self.config.alpha,
555 stop_on_providers,
556 query,
557 )
558 .await
559 }
560
561 /// Fold discovered contacts back into the routing table (skipping ourselves). Applies the LRS
562 /// insert policy; a full bucket's [`InsertOutcome::Full`] is left for the ping-and-replace
563 /// maintenance (we do not ping inline to keep lookups fast).
564 ///
565 /// `contacts` come straight off the wire (a peer's `find_node`/`find_providers` response) and
566 /// so bypass [`Contact::new`]'s address cap (its fields are public) — this is another
567 /// untrusted-input boundary (SPEC §5.5, §14), capped here before insertion.
568 async fn absorb_contacts(&self, contacts: &[Contact]) {
569 let mut rt = self.routing.lock().await;
570 for c in contacts {
571 let mut c = c.clone();
572 crate::record::sort_and_cap_addresses(&mut c.addresses);
573 match rt.insert(c) {
574 InsertOutcome::Inserted => {}
575 InsertOutcome::Full { .. } => {
576 // Bucket full — leave for ping-and-replace; do not block the lookup on a ping.
577 }
578 }
579 }
580 }
581
582 /// PUT `record` at each of `peers` via `add_provider`, counting acceptances. A peer that errors
583 /// is skipped (best-effort replication — the record survives at the peers that accepted + locally).
584 async fn put_record_at(&self, peers: &[Contact], record: &ProviderRecord) -> usize {
585 let req = DhtRequest::AddProvider {
586 record: record.clone(),
587 };
588 let from = self.local_contact();
589 let mut accepted = 0;
590 for p in peers {
591 if p.peer_id == self.local_id.to_hex() {
592 continue; // already stored locally
593 }
594 if let Ok(DhtResponse::AddProviderOk) = self.transport.rpc(&from, p, &req).await {
595 accepted += 1;
596 }
597 }
598 accepted
599 }
600
601 /// A random key whose distance from this node falls in bucket `idx` (so a refresh lookup targets
602 /// that bucket's region). Sets the bit at position `255 - idx` and randomizes the lower bits.
603 fn random_key_in_bucket(&self, idx: usize) -> Key {
604 let local = *self.local_id.as_bytes();
605 let mut distance = [0u8; 32];
606 let bit = 255 - idx; // MSB-set position for this bucket
607 let byte = bit / 8;
608 let bit_in_byte = 7 - (bit % 8);
609 distance[byte] = 1 << bit_in_byte;
610 // Randomize lower-significant bits so successive refreshes vary the target.
611 for b in distance.iter_mut().skip(byte + 1) {
612 *b = rand::random::<u8>();
613 }
614 let mut target = [0u8; 32];
615 for i in 0..32 {
616 target[i] = local[i] ^ distance[i];
617 }
618 Key::from_bytes(target)
619 }
620
621 /// The contacts currently in this node's routing table closest to `target` (diagnostic /
622 /// introspection — the peers this node knows without any network round-trip).
623 pub async fn known_closest(&self, target: &Key) -> Vec<Contact> {
624 self.routing.lock().await.closest(target)
625 }
626
627 /// The number of peers currently in this node's routing table (diagnostic / metrics).
628 pub async fn routing_len(&self) -> usize {
629 self.routing.lock().await.len()
630 }
631}
632
633/// Current wall-clock Unix seconds (saturating to 0 before the epoch), for provider TTLs.
634fn now_secs() -> u64 {
635 SystemTime::now()
636 .duration_since(UNIX_EPOCH)
637 .unwrap_or_default()
638 .as_secs()
639}
640
641/// Parse a 64-hex string into a [`Key`] (used on the serving side for wire targets).
642fn parse_key(hex: &str) -> Option<Key> {
643 hex64_to_bytes(hex).map(Key::from_bytes)
644}
645
646/// Decode a 64-char hex string to 32 bytes.
647fn hex64_to_bytes(hex: &str) -> Option<[u8; 32]> {
648 if hex.len() != 64 {
649 return None;
650 }
651 let mut out = [0u8; 32];
652 let bytes = hex.as_bytes();
653 for (i, chunk) in bytes.chunks(2).enumerate() {
654 let hi = (chunk[0] as char).to_digit(16)?;
655 let lo = (chunk[1] as char).to_digit(16)?;
656 out[i] = ((hi << 4) | lo) as u8;
657 }
658 Some(out)
659}
660
661#[cfg(test)]
662mod tests {
663 use super::*;
664
665 fn key_hex_round_trips() {
666 // sanity for the local hex helper
667 }
668
669 #[test]
670 fn hex64_round_trip() {
671 let bytes = [0xABu8; 32];
672 let hex = Key::from_bytes(bytes).to_hex();
673 assert_eq!(hex64_to_bytes(&hex).unwrap(), bytes);
674 assert!(hex64_to_bytes("short").is_none());
675 assert!(hex64_to_bytes(&"zz".repeat(32)).is_none());
676 key_hex_round_trips();
677 }
678
679 #[test]
680 fn parse_key_rejects_bad_hex() {
681 assert!(parse_key("nothex").is_none());
682 assert!(parse_key(&"00".repeat(32)).is_some());
683 }
684}