dig_dht/lookup.rs
1//! The iterative Kademlia lookup: converge on the `k` closest peers to a target key by repeatedly
2//! querying the `α` closest un-queried peers we know, folding their returned closer contacts back
3//! into the shortlist, until no closer peer can be found.
4//!
5//! This is the heart of a Kademlia DHT. [`iterative_find`] drives it:
6//!
7//! 1. Seed a **shortlist** with the closest contacts we already know (routing table + bootstrap).
8//! 2. Each round, pick the `α` closest **un-queried** contacts and query them in parallel with the
9//! supplied `query` closure (a `find_node` for node lookup, or a `find_providers` for provider
10//! lookup — the closure returns both closer contacts AND any providers it collected).
11//! 3. Merge every returned contact into the shortlist (dedup by `peer_id`), keeping it sorted by XOR
12//! distance to the target and capped so it stays bounded.
13//! 4. Stop when a full round of the `k` closest have all been queried and none produced a strictly
14//! closer un-queried contact — the shortlist has converged. Return the `k` closest and any
15//! providers collected.
16//!
17//! The closure abstraction means node-lookup and provider-lookup share ONE convergence engine; only
18//! what each query returns differs. Transport failures to individual peers are non-fatal — a peer
19//! that errors is simply marked queried and the walk continues (the service's query closure maps a
20//! transport error to `Err(())`).
21//!
22//! Each round's `α`-sized batch is queried **truly concurrently**: every peer's RPC is
23//! [`tokio::spawn`]ed as its own task, so all `α` requests are in flight at once rather than
24//! awaited one at a time. A round of `α` peers that each stall to the transport timeout therefore
25//! costs about one `rpc_timeout`, not `α × rpc_timeout`.
26
27use std::collections::{HashMap, HashSet};
28use std::future::Future;
29
30use crate::key::Key;
31use crate::record::ProviderRecord;
32use crate::routing::Contact;
33
34/// Absolute ceiling on the number of iterative rounds a single lookup may run (#1352). A converging
35/// Kademlia lookup needs O(log n) rounds; this bound only ever trips on a pathological/adversarial
36/// swarm where responders keep feeding an endless stream of ever-closer contacts to prevent
37/// convergence (a lookup-livelock DoS). 64 rounds is far beyond any honest convergence depth.
38pub const MAX_LOOKUP_ROUNDS: usize = 64;
39
40/// Baseline ceiling on how many provider records a single peer's response may contribute to a
41/// lookup (#1352). An honest responder returns at most its per-key cap (`max_providers_per_key`,
42/// default 20) live records; a response off the wire is untrusted and could otherwise pack an
43/// unbounded set into one frame, inflating the collected-providers map. The effective cap is the
44/// larger of this and `2·k`, so a network tuned to a large `k` is never under-bounded.
45pub const MAX_PROVIDERS_PER_RESPONSE: usize = 64;
46
47/// Baseline ceiling on how many `closer` contacts a single peer's response may contribute to a
48/// lookup (#1352). An honest responder returns at most `k`; the effective cap is the larger of this
49/// and `2·k`. Bounds the per-response merge work an adversarial peer can impose.
50pub const MAX_CLOSER_PER_RESPONSE: usize = 64;
51
52/// The outcome of querying one peer during a lookup: the closer contacts it knows, and any provider
53/// records it holds for the target (empty for a pure node lookup).
54#[derive(Debug, Default, Clone)]
55pub struct QueryOutcome {
56 /// Contacts the queried peer returned as closer to the target (its `find_node` answer).
57 pub closer: Vec<Contact>,
58 /// Provider records the queried peer holds for the target key (its `find_providers` answer).
59 pub providers: Vec<ProviderRecord>,
60}
61
62/// The result of a completed iterative lookup.
63#[derive(Debug, Default, Clone)]
64pub struct LookupResult {
65 /// The `k` closest contacts to the target the lookup converged on (closest-first).
66 pub closest: Vec<Contact>,
67 /// All distinct provider records collected across the walk (dedup by provider `peer_id`).
68 pub providers: Vec<ProviderRecord>,
69}
70
71/// A single node in the lookup shortlist: a contact, its precomputed distance to the target, and
72/// whether we have queried it yet.
73struct ShortlistEntry {
74 contact: Contact,
75 distance: crate::key::Distance,
76 queried: bool,
77 /// A peer that returned an error / was unreachable — counts as queried, never re-tried.
78 failed: bool,
79}
80
81/// Drive an iterative lookup toward `target`, seeded with `seeds`, querying at most `alpha` peers per
82/// round and converging on the `k` closest.
83///
84/// `query` is invoked per peer to talk to it; it returns the peer's [`QueryOutcome`] (closer
85/// contacts + any providers). An `Err` from `query` marks that peer failed and the walk continues —
86/// a single unreachable peer never aborts the lookup. `stop_on_providers`, when `true`, ends the
87/// lookup as soon as at least one provider has been collected (used by `find_providers`, which wants
88/// the first holders fast, not the exhaustive `k`-closest walk).
89///
90/// The `query` closure is cloned per peer, so it must be `Clone` (the service passes an `Arc`-backed
91/// closure over the transport). It must also be `Send + 'static` (and its future `Send + 'static`)
92/// because each peer's query is [`tokio::spawn`]ed to run the round's `α`-sized batch concurrently.
93pub async fn iterative_find<F, Fut>(
94 target: Key,
95 seeds: Vec<Contact>,
96 k: usize,
97 alpha: usize,
98 stop_on_providers: bool,
99 query: F,
100) -> LookupResult
101where
102 F: Fn(Contact) -> Fut + Clone + Send + 'static,
103 Fut: Future<Output = Result<QueryOutcome, ()>> + Send + 'static,
104{
105 let mut shortlist: Vec<ShortlistEntry> = Vec::new();
106 let mut seen: HashSet<String> = HashSet::new();
107 // Collected providers, deduped by provider peer_id.
108 let mut providers: HashMap<String, ProviderRecord> = HashMap::new();
109
110 // Untrusted-response ceilings (#1352): bound how much one peer's response may contribute, so a
111 // single adversarial responder cannot inflate the collected-providers map or the per-round merge
112 // work. Scaled up for a large-`k` network so an honest responder is never truncated.
113 let max_providers_per_response = MAX_PROVIDERS_PER_RESPONSE.max(k.saturating_mul(2));
114 let max_closer_per_response = MAX_CLOSER_PER_RESPONSE.max(k.saturating_mul(2));
115
116 // Seed.
117 for c in seeds {
118 merge_contact(&mut shortlist, &mut seen, &target, c);
119 }
120 sort_and_cap(&mut shortlist, k, alpha);
121
122 // Round guard (#1352): an absolute cap so an adversarial swarm feeding endless ever-closer
123 // contacts cannot livelock the lookup — it always terminates in at most `MAX_LOOKUP_ROUNDS`.
124 let mut rounds = 0;
125 loop {
126 rounds += 1;
127 if rounds > MAX_LOOKUP_ROUNDS {
128 break;
129 }
130 // Pick the α closest un-queried, non-failed entries.
131 let batch: Vec<Contact> = shortlist
132 .iter()
133 .filter(|e| !e.queried && !e.failed)
134 .take(alpha)
135 .map(|e| e.contact.clone())
136 .collect();
137
138 if batch.is_empty() {
139 break; // nothing left to ask → converged
140 }
141
142 // Query the batch CONCURRENTLY: spawn each peer's RPC as its own task so all `alpha`
143 // requests are in flight at once, instead of awaiting them one at a time (which would cost
144 // up to alpha * rpc_timeout when peers stall — SECURITY_AUDIT_P2P.md #179).
145 let mut set = tokio::task::JoinSet::new();
146 for c in &batch {
147 let q = query.clone();
148 let c2 = c.clone();
149 set.spawn(async move { (c2.peer_id.clone(), q(c2).await) });
150 }
151 let mut results = Vec::with_capacity(batch.len());
152 while let Some(joined) = set.join_next().await {
153 match joined {
154 Ok(pair) => results.push(pair),
155 Err(_join_err) => {
156 // A spawned query task panicked or was cancelled. We don't know which peer_id
157 // it was (the JoinError doesn't carry our closure's captured id), so it cannot
158 // be marked queried/failed individually; the lookup still terminates because
159 // the batch-empty / converged checks below do not depend on every task
160 // succeeding, and a peer that never reports back simply stays eligible to be
161 // re-picked in the extremely unlikely event this happens (task panics are not
162 // expected from the query closure's Result-returning contract).
163 }
164 }
165 }
166
167 // Fold results back in.
168 for (peer_id, res) in results {
169 mark_queried(&mut shortlist, &peer_id, res.is_err());
170 if let Ok(mut outcome) = res {
171 // Truncate each list to the untrusted-response ceiling (#1352) BEFORE folding, so a
172 // single peer cannot inflate the providers map or impose unbounded merge work.
173 outcome.providers.truncate(max_providers_per_response);
174 outcome.closer.truncate(max_closer_per_response);
175 for p in outcome.providers {
176 providers.entry(p.provider_peer_id.clone()).or_insert(p);
177 }
178 for c in outcome.closer {
179 merge_contact(&mut shortlist, &mut seen, &target, c);
180 }
181 }
182 }
183 sort_and_cap(&mut shortlist, k, alpha);
184
185 if stop_on_providers && !providers.is_empty() {
186 break;
187 }
188
189 // Converged? If the α closest are all queried/failed and no un-queried peer is closer than
190 // the k-th closest already-queried peer, we are done. The batch-empty check at the top of the
191 // next loop handles the general case; this early-out avoids an extra idle round.
192 let any_unqueried_in_top_k = shortlist.iter().take(k).any(|e| !e.queried && !e.failed);
193 if !any_unqueried_in_top_k {
194 break;
195 }
196 }
197
198 let closest = shortlist
199 .into_iter()
200 .filter(|e| !e.failed)
201 .take(k)
202 .map(|e| e.contact)
203 .collect();
204 LookupResult {
205 closest,
206 providers: providers.into_values().collect(),
207 }
208}
209
210/// Insert `contact` into the shortlist if new (dedup by `peer_id`), computing its distance to the
211/// target. A contact with a malformed `peer_id` (no key) is skipped.
212fn merge_contact(
213 shortlist: &mut Vec<ShortlistEntry>,
214 seen: &mut HashSet<String>,
215 target: &Key,
216 contact: Contact,
217) {
218 if seen.contains(&contact.peer_id) {
219 return;
220 }
221 let Some(key) = contact.key() else {
222 return;
223 };
224 seen.insert(contact.peer_id.clone());
225 let distance = target.distance(&key);
226 shortlist.push(ShortlistEntry {
227 contact,
228 distance,
229 queried: false,
230 failed: false,
231 });
232}
233
234/// Mark the entry for `peer_id` as queried (and failed if the query errored).
235fn mark_queried(shortlist: &mut [ShortlistEntry], peer_id: &str, failed: bool) {
236 if let Some(e) = shortlist.iter_mut().find(|e| e.contact.peer_id == peer_id) {
237 e.queried = true;
238 e.failed = failed;
239 }
240}
241
242/// Keep the shortlist sorted closest-first and bounded. We keep more than `k` so late-arriving
243/// closer contacts still have room, but cap it to avoid unbounded growth on a large network.
244fn sort_and_cap(shortlist: &mut Vec<ShortlistEntry>, k: usize, alpha: usize) {
245 shortlist.sort_by_key(|e| e.distance);
246 // A generous cap: the k closest we will return, plus headroom for α parallel probes and their
247 // returned neighbours. Bounds memory without discarding a contact that could still be in the top-k.
248 let cap = (k * 3).max(k + alpha * 2);
249 if shortlist.len() > cap {
250 shortlist.truncate(cap);
251 }
252}
253
254#[cfg(test)]
255mod tests {
256 use super::*;
257 use crate::record::CandidateAddr;
258 use dig_nat::PeerId;
259 use std::sync::atomic::{AtomicUsize, Ordering};
260 use std::sync::Arc;
261
262 fn contact_from_key(key_bytes: [u8; 32]) -> Contact {
263 Contact::new(
264 &PeerId::from_bytes(key_bytes),
265 vec![CandidateAddr::direct("h", 1)],
266 )
267 }
268
269 /// A simulated network: each node knows the `k` closest of all node ids to any target (a perfect
270 /// oracle neighbour set). This lets us assert convergence to the true k-closest.
271 fn oracle_query(
272 all_ids: Vec<[u8; 32]>,
273 target: Key,
274 k: usize,
275 counter: Arc<AtomicUsize>,
276 ) -> impl Fn(Contact) -> std::pin::Pin<Box<dyn Future<Output = Result<QueryOutcome, ()>> + Send>>
277 + Clone {
278 move |_c: Contact| {
279 counter.fetch_add(1, Ordering::SeqCst);
280 let all_ids = all_ids.clone();
281 Box::pin(async move {
282 let mut sorted: Vec<[u8; 32]> = all_ids;
283 sorted.sort_by_key(|id| *target.distance(&Key::from_bytes(*id)).as_bytes());
284 let closer = sorted.into_iter().take(k).map(contact_from_key).collect();
285 Ok(QueryOutcome {
286 closer,
287 providers: vec![],
288 })
289 })
290 }
291 }
292
293 #[tokio::test]
294 async fn converges_to_k_closest_in_simulated_network() {
295 // 50 node ids with varied top bytes.
296 let all_ids: Vec<[u8; 32]> = (0u8..50)
297 .map(|i| {
298 let mut b = [0u8; 32];
299 b[0] = i.wrapping_mul(5);
300 b[1] = i;
301 b
302 })
303 .collect();
304 let target = Key::from_bytes([0u8; 32]);
305 let k = 20;
306 let counter = Arc::new(AtomicUsize::new(0));
307
308 // Seed with just a couple of arbitrary (possibly far) nodes.
309 let seeds = vec![contact_from_key(all_ids[40]), contact_from_key(all_ids[45])];
310 let query = oracle_query(all_ids.clone(), target, k, counter.clone());
311
312 let result = iterative_find(target, seeds, k, 3, false, query).await;
313
314 // Compute the true k-closest.
315 let mut expected = all_ids.clone();
316 expected.sort_by_key(|id| *target.distance(&Key::from_bytes(*id)).as_bytes());
317 let expected_top: Vec<Contact> =
318 expected.into_iter().take(k).map(contact_from_key).collect();
319
320 assert_eq!(result.closest.len(), k);
321 // The closest returned must equal the true k-closest as a set.
322 let got: HashSet<String> = result.closest.iter().map(|c| c.peer_id.clone()).collect();
323 let exp: HashSet<String> = expected_top.iter().map(|c| c.peer_id.clone()).collect();
324 assert_eq!(got, exp, "lookup must converge on the true k-closest");
325 // The closest entry must be the globally closest id.
326 assert_eq!(result.closest[0].peer_id, expected_top[0].peer_id);
327 }
328
329 #[tokio::test]
330 async fn empty_seeds_returns_empty() {
331 let target = Key::from_bytes([0u8; 32]);
332 let result = iterative_find(target, vec![], 20, 3, false, |_c: Contact| async {
333 Ok(QueryOutcome::default())
334 })
335 .await;
336 assert!(result.closest.is_empty());
337 assert!(result.providers.is_empty());
338 }
339
340 #[tokio::test]
341 async fn stop_on_providers_ends_early() {
342 let target = Key::from_bytes([0u8; 32]);
343 let seed = contact_from_key([0x10; 32]);
344 let provider = ProviderRecord::new(
345 &target,
346 &PeerId::from_bytes([0xAB; 32]),
347 vec![CandidateAddr::direct("h", 9444)],
348 u64::MAX,
349 );
350 let p2 = provider.clone();
351 let result = iterative_find(target, vec![seed], 20, 3, true, move |_c: Contact| {
352 let p = p2.clone();
353 async move {
354 Ok(QueryOutcome {
355 closer: vec![],
356 providers: vec![p],
357 })
358 }
359 })
360 .await;
361 assert_eq!(result.providers.len(), 1);
362 assert_eq!(
363 result.providers[0].provider_peer_id,
364 provider.provider_peer_id
365 );
366 }
367
368 #[tokio::test]
369 async fn failed_peers_do_not_abort_lookup() {
370 // Every query fails → lookup still terminates, returns no closer/providers, no panic/hang.
371 let target = Key::from_bytes([0u8; 32]);
372 let seeds = vec![contact_from_key([0x01; 32]), contact_from_key([0x02; 32])];
373 let result = iterative_find(target, seeds, 20, 3, false, |_c: Contact| async {
374 Err::<QueryOutcome, ()>(())
375 })
376 .await;
377 // Failed seeds are excluded from `closest`.
378 assert!(result.closest.is_empty());
379 }
380
381 #[tokio::test]
382 async fn dedups_providers_by_peer_id() {
383 let target = Key::from_bytes([0u8; 32]);
384 let seeds = vec![contact_from_key([0x01; 32]), contact_from_key([0x02; 32])];
385 let provider =
386 ProviderRecord::new(&target, &PeerId::from_bytes([0xAB; 32]), vec![], u64::MAX);
387 let p = provider.clone();
388 // Both seeds return the SAME provider → must be deduped to 1.
389 let result = iterative_find(target, seeds, 20, 3, false, move |_c: Contact| {
390 let p = p.clone();
391 async move {
392 Ok(QueryOutcome {
393 closer: vec![],
394 providers: vec![p],
395 })
396 }
397 })
398 .await;
399 assert_eq!(result.providers.len(), 1);
400 }
401
402 // ---- Untrusted-response caps (#1352) ----
403
404 #[tokio::test]
405 async fn providers_per_response_is_capped() {
406 // One seed returns a flood of DISTINCT providers in a single response; the collected set must
407 // be bounded by the untrusted-response ceiling, not the raw flood.
408 let target = Key::from_bytes([0u8; 32]);
409 let seed = contact_from_key([0x10; 32]);
410 let flood: Vec<ProviderRecord> = (0u32..10_000)
411 .map(|i| {
412 let mut b = [0u8; 32];
413 b[0..4].copy_from_slice(&i.to_be_bytes());
414 ProviderRecord::new(&target, &PeerId::from_bytes(b), vec![], u64::MAX)
415 })
416 .collect();
417 let result = iterative_find(target, vec![seed], 20, 3, false, move |_c: Contact| {
418 let flood = flood.clone();
419 async move {
420 Ok(QueryOutcome {
421 closer: vec![],
422 providers: flood,
423 })
424 }
425 })
426 .await;
427 let cap = MAX_PROVIDERS_PER_RESPONSE.max(20 * 2);
428 assert!(
429 result.providers.len() <= cap,
430 "providers-per-response must be capped at {cap}, got {}",
431 result.providers.len()
432 );
433 }
434
435 #[tokio::test]
436 async fn round_guard_terminates_a_non_converging_lookup() {
437 // An adversarial responder that ALWAYS returns a brand-new, strictly-closer contact would
438 // livelock a lookup forever without a round cap. The round guard (#1352) must bound total
439 // work: at most MAX_LOOKUP_ROUNDS rounds of alpha queries (+ the seed).
440 let target = Key::from_bytes([0xFF; 32]);
441 let seed = contact_from_key([0x00; 32]);
442 let calls = Arc::new(AtomicUsize::new(0));
443 let counter = calls.clone();
444 const ALPHA: usize = 3;
445 let result = iterative_find(target, vec![seed], 20, ALPHA, false, move |_c: Contact| {
446 let n = counter.fetch_add(1, Ordering::SeqCst);
447 // Each call fabricates a unique contact ever-closer to the target (high bytes = 0xFF),
448 // so the shortlist never converges — only the round guard stops it.
449 let mut b = [0xFFu8; 32];
450 b[24..32].copy_from_slice(&(n as u64).to_be_bytes());
451 async move {
452 Ok(QueryOutcome {
453 closer: vec![contact_from_key(b)],
454 providers: vec![],
455 })
456 }
457 })
458 .await;
459 let total = calls.load(Ordering::SeqCst);
460 assert!(
461 total <= MAX_LOOKUP_ROUNDS * ALPHA + 1,
462 "round guard must bound total queries to ~MAX_LOOKUP_ROUNDS*alpha, got {total}"
463 );
464 // It still terminates and returns a bounded result (proof it did not hang).
465 assert!(result.closest.len() <= 20);
466 }
467
468 // ---- Concurrency (MEDIUM/optimization: join_all awaits sequentially, SECURITY_AUDIT_P2P.md #179) ----
469
470 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
471 async fn alpha_batch_is_queried_concurrently_not_sequentially() {
472 // Seed exactly `alpha` peers who each "stall" for DELAY, and never return closer contacts
473 // (so the walk needs exactly one round). If the batch is awaited sequentially, wall clock
474 // is ~alpha * DELAY; if concurrent, it is ~1 * DELAY. Assert well under alpha * DELAY so a
475 // regression back to sequential awaiting fails this test.
476 const ALPHA: usize = 4;
477 const DELAY: std::time::Duration = std::time::Duration::from_millis(150);
478
479 let target = Key::from_bytes([0u8; 32]);
480 let seeds: Vec<Contact> = (1u8..=ALPHA as u8)
481 .map(|i| contact_from_key([i; 32]))
482 .collect();
483
484 let start = std::time::Instant::now();
485 let result = iterative_find(target, seeds, 20, ALPHA, false, |_c: Contact| async move {
486 tokio::time::sleep(DELAY).await;
487 Ok(QueryOutcome::default())
488 })
489 .await;
490 let elapsed = start.elapsed();
491
492 assert_eq!(result.closest.len(), ALPHA, "all alpha peers answered");
493 assert!(
494 elapsed < DELAY * (ALPHA as u32) / 2,
495 "batch must run concurrently: expected well under {:?} (alpha * delay), got {:?}",
496 DELAY * (ALPHA as u32),
497 elapsed
498 );
499 }
500}