Skip to main content

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}