Skip to main content

dig_dht/
routing.rs

1//! The Kademlia routing table: 256 k-buckets of [`Contact`]s, keyed by XOR-distance
2//! [`bucket_index`](crate::key::Distance::bucket_index) from this node.
3//!
4//! A node keeps, for each possible shared-prefix length with its own id, a bucket of up to `k`
5//! contacts it knows at that distance. Buckets are **least-recently-seen ordered**: a fresh contact
6//! goes to the back (most-recently-seen); when a full bucket gets a new contact, Kademlia keeps the
7//! **existing** least-recently-seen node if it is still alive (long-lived nodes are the most
8//! valuable — they resist eviction attacks) and drops the newcomer, only replacing the LRS node once
9//! it is confirmed dead. This module implements that policy with an explicit
10//! [`InsertOutcome`] so the service can ping-and-replace.
11//!
12//! [`closest`](RoutingTable::closest) returns the `k` contacts nearest a target across all buckets —
13//! the seed set for an iterative lookup.
14
15use serde::{Deserialize, Serialize};
16
17use dig_nat::PeerId;
18
19use crate::key::Key;
20use crate::record::{sort_and_cap_addresses, CandidateAddr};
21
22/// A known peer in the routing table / on the wire: its `peer_id` (64-hex) and candidate addresses.
23///
24/// This is the DHT's contact record and the `find_node` / `find_providers.closer` wire shape. The
25/// `addresses` are the same `{ host, port, kind }` candidates as an L7 `dig.getPeers` peer, so a
26/// contact converts directly to a `dig_nat::PeerTarget`.
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28pub struct Contact {
29    /// The peer's identity — 64-hex `peer_id = SHA-256(SPKI DER)`.
30    pub peer_id: String,
31    /// Candidate addresses to reach the peer. Ordered IPv6-first, then most-direct-first by
32    /// [`AddressKind::rank`](crate::record::AddressKind::rank) when built via [`Contact::new`]; a
33    /// `Contact` deserialized directly from the wire (bypassing `new`) is not guaranteed sorted, so
34    /// a consumer that cannot assume a conforming producer should still sort defensively.
35    pub addresses: Vec<CandidateAddr>,
36}
37
38impl Contact {
39    /// Build a contact from a decoded [`PeerId`] and its candidate addresses. `addresses` is sorted
40    /// IPv6-first-then-rank (see `sort_addresses_ipv6_first`) — ecosystem-wide IPv6-first,
41    /// IPv4-fallback for peer communication.
42    pub fn new(peer_id: &PeerId, mut addresses: Vec<CandidateAddr>) -> Self {
43        sort_and_cap_addresses(&mut addresses);
44        Contact {
45            peer_id: peer_id.to_hex(),
46            addresses,
47        }
48    }
49
50    /// The peer's [`PeerId`] decoded from the 64-hex field, or `None` if malformed.
51    pub fn peer_id(&self) -> Option<PeerId> {
52        PeerId::from_hex(&self.peer_id)
53    }
54
55    /// This contact's key in the DHT keyspace (its `peer_id`), or `None` if the hex is malformed.
56    pub fn key(&self) -> Option<Key> {
57        self.peer_id().as_ref().map(Key::from_peer_id)
58    }
59
60    /// The IPv6-preferred, most-direct dialable candidate address, if any. `addresses` is already
61    /// IPv6-first-then-rank sorted, so this is simply the first dialable entry.
62    pub fn best_address(&self) -> Option<&CandidateAddr> {
63        self.addresses.iter().find(|a| a.kind.is_dialable())
64    }
65}
66
67/// What happened when a contact was offered to a full bucket — drives the service's
68/// ping-and-replace maintenance.
69#[derive(Debug, Clone, PartialEq, Eq)]
70pub enum InsertOutcome {
71    /// The contact was inserted (bucket had room, or it was already present and got refreshed).
72    Inserted,
73    /// The bucket is full. The `candidate` was NOT inserted; the caller should ping `lru` (the
74    /// least-recently-seen contact) and, if it fails to respond, call
75    /// [`RoutingTable::replace`] to evict it for the candidate.
76    Full {
77        /// The least-recently-seen contact currently holding the contested slot.
78        lru: Contact,
79    },
80}
81
82/// One k-bucket: up to `capacity` (= `k`) contacts, least-recently-seen at the FRONT, most-recently
83/// seen at the BACK.
84#[derive(Debug, Clone)]
85struct KBucket {
86    contacts: Vec<Contact>,
87    capacity: usize,
88}
89
90impl KBucket {
91    fn new(capacity: usize) -> Self {
92        KBucket {
93            contacts: Vec::new(),
94            capacity,
95        }
96    }
97
98    /// Offer `contact` to the bucket (LRS-ordered):
99    /// - already present → move to the back (mark most-recently-seen) → [`InsertOutcome::Inserted`].
100    /// - room available → push to the back → [`InsertOutcome::Inserted`].
101    /// - full → [`InsertOutcome::Full`] naming the LRS contact for the caller to ping-and-replace.
102    fn offer(&mut self, contact: Contact) -> InsertOutcome {
103        if let Some(pos) = self
104            .contacts
105            .iter()
106            .position(|c| c.peer_id == contact.peer_id)
107        {
108            // Seen again: refresh recency by moving to the back, updating addresses.
109            self.contacts.remove(pos);
110            self.contacts.push(contact);
111            return InsertOutcome::Inserted;
112        }
113        if self.contacts.len() < self.capacity {
114            self.contacts.push(contact);
115            return InsertOutcome::Inserted;
116        }
117        // Full — the least-recently-seen contact is at the front.
118        InsertOutcome::Full {
119            lru: self.contacts[0].clone(),
120        }
121    }
122
123    /// Evict `lru_peer_id` (if it is still the front contact) and insert `candidate` at the back.
124    /// No-op returning `false` if the front contact changed since the [`InsertOutcome::Full`] (the
125    /// LRU responded to a ping and got refreshed, or the bucket changed) — the candidate is dropped.
126    fn replace(&mut self, lru_peer_id: &str, candidate: Contact) -> bool {
127        match self.contacts.first() {
128            Some(front) if front.peer_id == lru_peer_id => {
129                self.contacts.remove(0);
130                self.contacts.push(candidate);
131                true
132            }
133            _ => false,
134        }
135    }
136
137    fn remove(&mut self, peer_id: &str) -> bool {
138        if let Some(pos) = self.contacts.iter().position(|c| c.peer_id == peer_id) {
139            self.contacts.remove(pos);
140            true
141        } else {
142            false
143        }
144    }
145}
146
147/// The routing table: 256 buckets keyed by shared-prefix length with `local_key`.
148///
149/// Bucket `i` holds contacts whose XOR distance from this node has
150/// [`bucket_index`](crate::key::Distance::bucket_index) `i` — i.e. contacts that share exactly
151/// `255 - i` leading bits with this node. Contacts closer in the tree land in low-index buckets.
152#[derive(Debug, Clone)]
153pub struct RoutingTable {
154    local_key: Key,
155    buckets: Vec<KBucket>,
156    k: usize,
157}
158
159impl RoutingTable {
160    /// A new empty table for the node whose id is `local_id`, with bucket size `k`.
161    pub fn new(local_id: &PeerId, k: usize) -> Self {
162        RoutingTable {
163            local_key: Key::from_peer_id(local_id),
164            buckets: (0..256).map(|_| KBucket::new(k)).collect(),
165            k,
166        }
167    }
168
169    /// This node's own key.
170    pub fn local_key(&self) -> Key {
171        self.local_key
172    }
173
174    /// The bucket index a contact with key `key` falls in (`None` for our own key).
175    fn bucket_for(&self, key: &Key) -> Option<usize> {
176        self.local_key.distance(key).bucket_index()
177    }
178
179    /// Offer a contact to the table. A contact whose key equals ours is ignored
180    /// ([`InsertOutcome::Inserted`], a no-op). Otherwise routes to the right bucket and applies the
181    /// LRS policy — see [`InsertOutcome`].
182    pub fn insert(&mut self, contact: Contact) -> InsertOutcome {
183        let Some(key) = contact.key() else {
184            // Malformed peer_id — reject silently as "inserted" (nothing to do; never a Full).
185            return InsertOutcome::Inserted;
186        };
187        match self.bucket_for(&key) {
188            None => InsertOutcome::Inserted, // our own id; never stored
189            Some(idx) => self.buckets[idx].offer(contact),
190        }
191    }
192
193    /// Complete a ping-and-replace: after an [`InsertOutcome::Full`] whose `lru` did not respond,
194    /// evict `lru` and insert `candidate`. Returns whether the eviction happened (false if the LRU
195    /// slot changed in the meantime — then `candidate` is dropped, per Kademlia).
196    pub fn replace(&mut self, lru: &Contact, candidate: Contact) -> bool {
197        let Some(key) = candidate.key() else {
198            return false;
199        };
200        match self.bucket_for(&key) {
201            None => false,
202            Some(idx) => self.buckets[idx].replace(&lru.peer_id, candidate),
203        }
204    }
205
206    /// Remove a contact (e.g. a peer that failed a liveness ping). Returns whether it was present.
207    pub fn remove(&mut self, peer_id: &str) -> bool {
208        let Some(pid) = PeerId::from_hex(peer_id) else {
209            return false;
210        };
211        match self.bucket_for(&Key::from_peer_id(&pid)) {
212            None => false,
213            Some(idx) => self.buckets[idx].remove(peer_id),
214        }
215    }
216
217    /// The `k` contacts closest (XOR distance) to `target` across all buckets, closest-first.
218    ///
219    /// This is the seed set for an iterative lookup and the answer to a `find_node`. It scans all
220    /// buckets (256 × ≤ k contacts) and returns the `k` with the smallest distance to `target`.
221    pub fn closest(&self, target: &Key) -> Vec<Contact> {
222        let mut all: Vec<(Contact, crate::key::Distance)> = self
223            .buckets
224            .iter()
225            .flat_map(|b| b.contacts.iter())
226            .filter_map(|c| c.key().map(|k| (c.clone(), target.distance(&k))))
227            .collect();
228        all.sort_by_key(|(_, dist)| *dist);
229        all.into_iter().take(self.k).map(|(c, _)| c).collect()
230    }
231
232    /// Total contacts stored across all buckets (diagnostics / tests).
233    pub fn len(&self) -> usize {
234        self.buckets.iter().map(|b| b.contacts.len()).sum()
235    }
236
237    /// Whether the table holds no contacts.
238    pub fn is_empty(&self) -> bool {
239        self.len() == 0
240    }
241
242    /// Indices of buckets that currently hold at least one contact (used by bucket-refresh to focus
243    /// on populated regions; empty buckets far from the node are never realistically filled).
244    pub fn non_empty_bucket_indices(&self) -> Vec<usize> {
245        self.buckets
246            .iter()
247            .enumerate()
248            .filter(|(_, b)| !b.contacts.is_empty())
249            .map(|(i, _)| i)
250            .collect()
251    }
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257    use crate::record::{AddressKind, CandidateAddr};
258
259    fn pid(bytes: [u8; 32]) -> PeerId {
260        PeerId::from_bytes(bytes)
261    }
262
263    fn contact(bytes: [u8; 32]) -> Contact {
264        Contact::new(&pid(bytes), vec![CandidateAddr::direct("h", 1)])
265    }
266
267    fn local() -> PeerId {
268        pid([0u8; 32])
269    }
270
271    #[test]
272    fn insert_and_len() {
273        let mut t = RoutingTable::new(&local(), 20);
274        assert!(t.is_empty());
275        let mut b = [0u8; 32];
276        b[0] = 0x80;
277        assert_eq!(t.insert(contact(b)), InsertOutcome::Inserted);
278        assert_eq!(t.len(), 1);
279    }
280
281    #[test]
282    fn own_id_is_never_stored() {
283        let mut t = RoutingTable::new(&local(), 20);
284        assert_eq!(t.insert(contact([0u8; 32])), InsertOutcome::Inserted);
285        assert_eq!(t.len(), 0);
286    }
287
288    #[test]
289    fn reinsert_refreshes_recency_not_count() {
290        let mut t = RoutingTable::new(&local(), 20);
291        let mut b = [0u8; 32];
292        b[0] = 0x01;
293        t.insert(contact(b));
294        t.insert(contact(b));
295        assert_eq!(t.len(), 1);
296    }
297
298    #[test]
299    fn full_bucket_reports_lru_and_does_not_grow() {
300        // Force all contacts into the SAME bucket (index 255 = MSB of distance set) with k = 2.
301        // Keys with the top bit set share bucket 255 relative to local id 0.
302        let mut t = RoutingTable::new(&local(), 2);
303        let mut a = [0u8; 32];
304        a[0] = 0x80;
305        a[31] = 0x01;
306        let mut b = [0u8; 32];
307        b[0] = 0x80;
308        b[31] = 0x02;
309        let mut c = [0u8; 32];
310        c[0] = 0x80;
311        c[31] = 0x03;
312        assert_eq!(t.insert(contact(a)), InsertOutcome::Inserted);
313        assert_eq!(t.insert(contact(b)), InsertOutcome::Inserted);
314        // Third into the full k=2 bucket → Full, naming the LRS (first inserted = `a`).
315        match t.insert(contact(c)) {
316            InsertOutcome::Full { lru } => assert_eq!(lru, contact(a)),
317            other => panic!("expected Full, got {other:?}"),
318        }
319        assert_eq!(t.len(), 2, "full bucket must not grow past k");
320    }
321
322    #[test]
323    fn replace_evicts_lru_for_candidate() {
324        let mut t = RoutingTable::new(&local(), 2);
325        let mut a = [0u8; 32];
326        a[0] = 0x80;
327        a[31] = 0x01;
328        let mut b = [0u8; 32];
329        b[0] = 0x80;
330        b[31] = 0x02;
331        let mut c = [0u8; 32];
332        c[0] = 0x80;
333        c[31] = 0x03;
334        t.insert(contact(a));
335        t.insert(contact(b));
336        let lru = match t.insert(contact(c)) {
337            InsertOutcome::Full { lru } => lru,
338            other => panic!("expected Full, got {other:?}"),
339        };
340        // LRU `a` "did not respond" → replace with candidate `c`.
341        assert!(t.replace(&lru, contact(c)));
342        assert_eq!(t.len(), 2);
343        // `a` gone, `c` present.
344        let keys: Vec<String> = t
345            .buckets
346            .iter()
347            .flat_map(|bk| bk.contacts.iter())
348            .map(|x| x.peer_id.clone())
349            .collect();
350        assert!(!keys.contains(&contact(a).peer_id));
351        assert!(keys.contains(&contact(c).peer_id));
352    }
353
354    #[test]
355    fn replace_is_noop_if_lru_slot_changed() {
356        let mut t = RoutingTable::new(&local(), 2);
357        let mut a = [0u8; 32];
358        a[0] = 0x80;
359        a[31] = 0x01;
360        let mut b = [0u8; 32];
361        b[0] = 0x80;
362        b[31] = 0x02;
363        let mut c = [0u8; 32];
364        c[0] = 0x80;
365        c[31] = 0x03;
366        t.insert(contact(a));
367        t.insert(contact(b));
368        let lru = match t.insert(contact(c)) {
369            InsertOutcome::Full { lru } => lru,
370            _ => unreachable!(),
371        };
372        // Simulate the LRU (`a`) responding to a ping → it is refreshed to the back.
373        t.insert(contact(a));
374        // Now the front is `b`, not `a`; replace(a, c) must be a no-op and drop `c`.
375        assert!(!t.replace(&lru, contact(c)));
376        assert_eq!(t.len(), 2);
377    }
378
379    #[test]
380    fn remove_contact() {
381        let mut t = RoutingTable::new(&local(), 20);
382        let mut b = [0u8; 32];
383        b[0] = 0x40;
384        let c = contact(b);
385        t.insert(c.clone());
386        assert!(t.remove(&c.peer_id));
387        assert!(!t.remove(&c.peer_id));
388        assert!(t.is_empty());
389    }
390
391    #[test]
392    fn closest_returns_k_sorted_by_distance() {
393        let mut t = RoutingTable::new(&local(), 20);
394        // Insert contacts at varied distances.
395        for i in 1u8..=10 {
396            let mut b = [0u8; 32];
397            b[0] = i; // distinct top byte → distinct distance from local 0
398            t.insert(contact(b));
399        }
400        let target = Key::from_bytes([0u8; 32]);
401        let closest = t.closest(&target);
402        assert_eq!(closest.len(), 10);
403        // Verify ascending distance order.
404        let dists: Vec<_> = closest
405            .iter()
406            .map(|c| target.distance(&c.key().unwrap()))
407            .collect();
408        for w in dists.windows(2) {
409            assert!(w[0] <= w[1], "closest must be distance-sorted");
410        }
411    }
412
413    #[test]
414    fn closest_caps_at_k() {
415        let mut t = RoutingTable::new(&local(), 3);
416        for i in 1u8..=10 {
417            let mut b = [0u8; 32];
418            b[0] = i;
419            t.insert(contact(b));
420        }
421        assert_eq!(t.closest(&Key::from_bytes([0u8; 32])).len(), 3);
422    }
423
424    #[test]
425    fn non_empty_bucket_indices_tracks_population() {
426        let mut t = RoutingTable::new(&local(), 20);
427        assert!(t.non_empty_bucket_indices().is_empty());
428        let mut b = [0u8; 32];
429        b[0] = 0x80; // bucket 255
430        t.insert(contact(b));
431        assert_eq!(t.non_empty_bucket_indices(), vec![255]);
432    }
433
434    #[test]
435    fn contact_key_and_best_address() {
436        let c = contact({
437            let mut b = [0u8; 32];
438            b[0] = 0x22;
439            b
440        });
441        assert!(c.key().is_some());
442        assert_eq!(c.best_address().unwrap().kind, AddressKindDirect());
443    }
444
445    // Small helper to avoid importing AddressKind into every assert.
446    #[allow(non_snake_case)]
447    fn AddressKindDirect() -> crate::record::AddressKind {
448        crate::record::AddressKind::Direct
449    }
450
451    #[test]
452    fn contact_new_sorts_addresses_ipv6_first() {
453        let c = Contact::new(
454            &pid([1u8; 32]),
455            vec![
456                CandidateAddr::direct("203.0.113.7", 9444), // IPv4 direct, fed first
457                CandidateAddr::direct("2001:db8::1", 9444), // IPv6 direct, fed second
458                CandidateAddr {
459                    host: "198.51.100.2".into(),
460                    port: 1,
461                    kind: AddressKind::Reflexive,
462                },
463                CandidateAddr {
464                    host: "2001:db8::2".into(),
465                    port: 1,
466                    kind: AddressKind::Reflexive,
467                },
468            ],
469        );
470        let hosts: Vec<&str> = c.addresses.iter().map(|a| a.host.as_str()).collect();
471        assert_eq!(
472            hosts,
473            vec!["2001:db8::1", "2001:db8::2", "203.0.113.7", "198.51.100.2"],
474            "contact addresses must be IPv6-first, then ranked by AddressKind"
475        );
476    }
477
478    #[test]
479    fn contact_best_address_prefers_ipv6_over_ipv4_at_same_rank() {
480        let c = Contact::new(
481            &pid([1u8; 32]),
482            vec![
483                CandidateAddr::direct("203.0.113.7", 9444), // IPv4 direct, fed first
484                CandidateAddr::direct("2001:db8::1", 9444), // IPv6 direct, fed second
485            ],
486        );
487        assert_eq!(c.best_address().unwrap().host, "2001:db8::1");
488    }
489
490    #[test]
491    fn contact_new_caps_addresses_at_the_constant() {
492        // MEDIUM (SECURITY_AUDIT_P2P.md #179): a contact must never carry an unbounded address
493        // list — memory blowup + outbound amplification when re-served to every querying peer.
494        let many: Vec<CandidateAddr> = (0..1000)
495            .map(|i| CandidateAddr::direct(format!("203.0.113.{}", i % 255), 9444))
496            .collect();
497        let c = Contact::new(&pid([1u8; 32]), many);
498        assert_eq!(c.addresses.len(), crate::record::MAX_ADDRESSES_PER_RECORD);
499    }
500}