decdn_node/dht/routing.rs
1//! Kademlia k-bucket routing table for `cdn/dht/v1` (ADR 022 §Routing Table).
2//!
3//! 256 buckets indexed by the XOR-distance prefix length to the node's own
4//! `NodeId`; each bucket holds up to `K = 20` peers ordered most-recently-seen
5//! last. The table is in-memory and rebuilt from the on-chain registry +
6//! `FindNode` self-lookup on restart (ADR 022 §Bootstrap).
7//!
8//! # Self-NodeId handling
9//!
10//! Inserting the node's own `NodeId` is a no-op: XOR distance is zero, the
11//! bucket index would underflow, and a node listing itself as a peer would
12//! cause iterative lookup to terminate on its own routing table without ever
13//! contacting the network.
14//!
15//! # Eviction policy
16//!
17//! On bucket overflow the least-recently-seen entry is evicted and the
18//! new `NodeId` appended at the tail. Standard Kademlia would ping the
19//! LRU first and evict only if it does not respond; ADR 022 §Routing
20//! Table does not mandate the ping-then-evict variant and we do not
21//! implement it. The simpler LRU here preserves the "newcomers are
22//! reachable" property — the LRU is the one least recently confirmed to
23//! be live, so evicting it is the safest bet among entries already in
24//! the bucket.
25
26use decdn_protocol::MAX_CLOSER_NODES;
27pub use decdn_protocol::NodeId;
28
29/// Number of bytes in an iroh `NodeId` (32-byte Ed25519 public key).
30/// Matches the `[u8; 32]` representation used on the wire in
31/// [`decdn_protocol::dht`].
32pub const NODE_ID_LEN: usize = 32;
33
34/// Number of bits in the keyspace — equals `NODE_ID_LEN * 8`. The routing
35/// table holds one bucket per possible XOR-prefix length.
36pub const KEYSPACE_BITS: usize = NODE_ID_LEN * 8;
37
38/// Kademlia bucket size (ADR 022 §Routing Table). Equal to
39/// `MAX_CLOSER_NODES` so a fully-populated bucket can be serialised straight
40/// into a `closer_nodes` field without re-truncation.
41pub const K_BUCKET_SIZE: usize = MAX_CLOSER_NODES;
42
43/// Bytewise XOR distance between two 32-byte keyspace points. Cheap (one AVX
44/// register on 64-bit) and the only function the bucket index depends on.
45///
46/// Operates on raw bytes so it serves the cross-domain case ADR 022 relies on:
47/// a [`NodeId`] and a `ContentHash` share the same 256-bit XOR keyspace, so the
48/// `FindValue` path measures a content hash's distance to candidate node ids
49/// with this primitive. [`xor_distance`] is the `NodeId`-vs-`NodeId` wrapper.
50#[must_use]
51pub fn xor_distance_bytes(a: &[u8; NODE_ID_LEN], b: &[u8; NODE_ID_LEN]) -> [u8; NODE_ID_LEN] {
52 let mut out = [0u8; NODE_ID_LEN];
53 for i in 0..NODE_ID_LEN {
54 // Indexing is bounded by the loop range; safe and the alternative
55 // (`zip` + collect) materialises a temporary.
56 if let (Some(o), Some(x), Some(y)) = (out.get_mut(i), a.get(i), b.get(i)) {
57 *o = x ^ y;
58 }
59 }
60 out
61}
62
63/// Bytewise XOR distance between two `NodeId`s. Thin wrapper over
64/// [`xor_distance_bytes`] for the common same-domain case.
65#[must_use]
66pub fn xor_distance(a: &NodeId, b: &NodeId) -> [u8; NODE_ID_LEN] {
67 xor_distance_bytes(a.as_bytes(), b.as_bytes())
68}
69
70/// Index of the bucket that holds peers at XOR distance `d` from the
71/// routing-table's own node id. The bucket index is the position of the
72/// most-significant set bit in `d` (counted from the high end), so peers
73/// that share a long prefix with the local node land in low-index buckets
74/// (close in keyspace) and peers with no shared prefix land in bucket 255.
75///
76/// Returns `None` when `d == 0` — the all-zero distance corresponds to the
77/// node's own identity, which is never stored in the table.
78#[must_use]
79pub(crate) fn bucket_index(d: &[u8; NODE_ID_LEN]) -> Option<usize> {
80 for (i, byte) in d.iter().enumerate() {
81 if *byte != 0 {
82 // Leading-zero count within the byte. `byte.leading_zeros()`
83 // returns 0..=8; combined with the byte index, the resulting
84 // bit position is unique to one bucket.
85 let bit_within = byte.leading_zeros() as usize;
86 // KEYSPACE_BITS - 1 - bit_from_msb. The inversion lines up the
87 // bucket index with shared-prefix length: bucket 255 holds the
88 // most-distant peers (high bit set in `d` => zero shared prefix
89 // bits => `bit_from_msb == 0`) and bucket 0 holds the closest
90 // peers (only the lowest bit of `d` differs => `bit_from_msb ==
91 // 255`).
92 let bit_from_msb = i * 8 + bit_within;
93 return Some(KEYSPACE_BITS - 1 - bit_from_msb);
94 }
95 }
96 None
97}
98
99/// In-memory k-bucket routing table (ADR 022 §Routing Table).
100///
101/// Not internally synchronised — the runtime owns it behind whatever mutex /
102/// `RwLock` the handler chooses. A bare `&mut self` interface keeps the
103/// happy-path hot loop (one `Vec::iter` + LRU bump) cache-friendly and lets
104/// callers compose with their own concurrency strategy.
105#[derive(Debug)]
106pub struct RoutingTable {
107 self_id: NodeId,
108 /// 256 buckets, each at most `K_BUCKET_SIZE` entries. Boxed so the
109 /// table itself stays a thin pointer on the stack — 256 × 20 × 32B is
110 /// 160 KiB and lives on the heap once.
111 buckets: Box<[Vec<NodeId>; KEYSPACE_BITS]>,
112}
113
114impl RoutingTable {
115 /// Construct an empty routing table anchored at `self_id`.
116 #[must_use]
117 pub fn new(self_id: NodeId) -> Self {
118 // `std::array::from_fn` builds the fixed-size array in place, so
119 // there is no intermediate `Vec` and no fallible length conversion
120 // — the type system guarantees we end up with exactly
121 // `KEYSPACE_BITS` empty buckets. We `Box` after the fact so the
122 // 160 KiB live on the heap rather than the stack.
123 let buckets: Box<[Vec<NodeId>; KEYSPACE_BITS]> =
124 Box::new(std::array::from_fn(|_| Vec::new()));
125 Self { self_id, buckets }
126 }
127
128 /// Own `NodeId` (the routing-table anchor).
129 #[must_use]
130 pub const fn self_id(&self) -> &NodeId {
131 &self.self_id
132 }
133
134 /// Total entries across all buckets. O(`KEYSPACE_BITS`).
135 #[must_use]
136 pub fn len(&self) -> usize {
137 self.buckets.iter().map(Vec::len).sum()
138 }
139
140 /// True iff the table has zero entries.
141 #[must_use]
142 pub fn is_empty(&self) -> bool {
143 self.buckets.iter().all(Vec::is_empty)
144 }
145
146 /// Insert or refresh `peer`. Returns `true` on a fresh insert, `false`
147 /// on a refresh (peer already present — its LRU position is moved to
148 /// the tail) or a self-insert (silently ignored).
149 ///
150 /// On bucket overflow the least-recently-seen entry is evicted to make
151 /// room. See the module docs for the LRU rationale vs. classic
152 /// ping-then-evict.
153 pub fn insert(&mut self, peer: NodeId) -> bool {
154 if peer == self.self_id {
155 return false;
156 }
157 let d = xor_distance(&self.self_id, &peer);
158 let Some(idx) = bucket_index(&d) else {
159 return false;
160 };
161 let Some(bucket) = self.buckets.get_mut(idx) else {
162 // bucket_index returns < KEYSPACE_BITS by construction.
163 return false;
164 };
165 if let Some(pos) = bucket.iter().position(|p| p == &peer) {
166 // Already present — move to tail (most-recently-seen).
167 let entry = bucket.remove(pos);
168 bucket.push(entry);
169 return false;
170 }
171 if bucket.len() >= K_BUCKET_SIZE {
172 // Evict least-recently-seen.
173 bucket.remove(0);
174 }
175 bucket.push(peer);
176 true
177 }
178
179 /// Remove `peer` if present. Returns `true` if a removal occurred.
180 pub fn remove(&mut self, peer: &NodeId) -> bool {
181 if *peer == self.self_id {
182 return false;
183 }
184 let d = xor_distance(&self.self_id, peer);
185 let Some(idx) = bucket_index(&d) else {
186 return false;
187 };
188 let Some(bucket) = self.buckets.get_mut(idx) else {
189 return false;
190 };
191 if let Some(pos) = bucket.iter().position(|p| p == peer) {
192 bucket.remove(pos);
193 return true;
194 }
195 false
196 }
197
198 /// True if `peer` is currently in the table.
199 #[must_use]
200 pub fn contains(&self, peer: &NodeId) -> bool {
201 if *peer == self.self_id {
202 return false;
203 }
204 let d = xor_distance(&self.self_id, peer);
205 let Some(idx) = bucket_index(&d) else {
206 return false;
207 };
208 self.buckets
209 .get(idx)
210 .is_some_and(|b| b.iter().any(|p| p == peer))
211 }
212
213 /// Return up to `n` peers from the table sorted by XOR distance to
214 /// `target` (closest first). Capped at `K_BUCKET_SIZE` since the wire
215 /// `closer_nodes` field is itself capped at `MAX_CLOSER_NODES`.
216 ///
217 /// Walks every bucket; with 256 × 20 entries the worst case is 5120
218 /// XOR computes (once each, cached) + a sort over those cached distances —
219 /// well under a microsecond on modern hardware and dwarfed by the QUIC
220 /// round-trip cost.
221 ///
222 /// The result is capped at [`K_BUCKET_SIZE`] (= [`MAX_CLOSER_NODES`])
223 /// so it can be serialised directly into a wire `closer_nodes` field.
224 /// Callers needing more than K candidates (e.g. ADR 022 §STORE Flow
225 /// "K+3 closest" republish fanout) MUST use [`Self::closest_unbounded`].
226 #[must_use]
227 pub fn closest(&self, target: &[u8; NODE_ID_LEN], n: usize) -> Vec<NodeId> {
228 let cap = n.min(K_BUCKET_SIZE);
229 self.closest_unbounded(target, cap)
230 }
231
232 /// Like [`Self::closest`] but without the wire-cap clamp at
233 /// [`K_BUCKET_SIZE`]. Used by the republish path which fans out to
234 /// K+3 closest peers per ADR 022 §STORE Flow line 128 ("three
235 /// positions beyond K are overflow targets — publishing to a wider
236 /// set than the minimum required by the routing geometry means an
237 /// attacker forcing record expiry by suppressing receivers must
238 /// take down K+3 hosts rather than K").
239 ///
240 /// MUST NOT be used to populate wire responses — those are bound at
241 /// [`MAX_CLOSER_NODES`] and a larger result would violate the
242 /// ADR 022 wire-cost ceiling. Use [`Self::closest`] for that path.
243 #[must_use]
244 pub fn closest_unbounded(&self, target: &[u8; NODE_ID_LEN], n: usize) -> Vec<NodeId> {
245 // Schwartzian transform: compute `xor_distance_bytes` exactly once per
246 // candidate, then sort on the cached distance. `sort_unstable_by_key`
247 // does NOT cache its key (that is what `sort_by_cached_key` is for), so
248 // sorting directly on the closure would recompute the distance
249 // `O(n log n)` times on this hot path. `target` is a raw keyspace point,
250 // so a `ContentHash` (FindValue) or a `NodeId` (FindNode) both fit.
251 let mut scored: Vec<(NodeId, [u8; NODE_ID_LEN])> = self
252 .buckets
253 .iter()
254 .flatten()
255 .map(|p| (*p, xor_distance_bytes(p.as_bytes(), target)))
256 .collect();
257 // Sorting on the already-computed distance only re-copies the cached
258 // 32-byte key during comparisons — `xor_distance_bytes` itself still
259 // runs exactly once per element above.
260 scored.sort_unstable_by_key(|&(_, dist)| dist);
261 scored.truncate(n);
262 scored.into_iter().map(|(p, _)| p).collect()
263 }
264
265 /// Iterator over every peer currently held. Order is bucket-major,
266 /// recency within bucket — useful for republish scans and bootstrap
267 /// snapshots, but **not** distance-sorted (callers wanting closeness
268 /// must use [`Self::closest`]).
269 pub fn iter_peers(&self) -> impl Iterator<Item = &NodeId> + '_ {
270 self.buckets.iter().flatten()
271 }
272
273 /// Per-bucket fill counts for the **non-empty** buckets only, as
274 /// `(index, fill)` pairs ordered by bucket index ascending. Read-only
275 /// snapshot for the `admin_v1_status` routing-table health view (issue
276 /// #741); empty buckets (the vast majority of the 256-bucket keyspace
277 /// on a small network) are omitted so the snapshot stays compact. The
278 /// per-bucket capacity is the fixed Kademlia [`K_BUCKET_SIZE`].
279 #[must_use]
280 pub fn non_empty_bucket_fills(&self) -> Vec<(usize, usize)> {
281 self.buckets
282 .iter()
283 .enumerate()
284 .filter(|(_, bucket)| !bucket.is_empty())
285 .map(|(idx, bucket)| (idx, bucket.len()))
286 .collect()
287 }
288}
289
290/// Construct a `NodeId` that lands in bucket `bucket_idx` relative to
291/// `self_id`, varied within the bucket by `salt`. Shared by both test modules
292/// below.
293///
294/// Flips the single distance bit that selects `bucket_idx` and buries `salt`
295/// in the last byte to vary peers within a bucket without changing the leading
296/// prefix. Note that for the lowest eight buckets the selecting bit lives in
297/// the last byte too, so a non-zero `salt` there can perturb the bucket;
298/// callers wanting a guaranteed bucket across all salts use `bucket_idx >= 8`
299/// (the selecting bit then sits in byte 30, which `salt` never touches).
300#[cfg(test)]
301fn id_in_bucket(self_id: &NodeId, bucket_idx: usize, salt: u8) -> NodeId {
302 // bucket_idx = KEYSPACE_BITS - 1 - bit_from_msb => bit_from_msb = 255 - bucket_idx
303 let bit_from_msb = KEYSPACE_BITS - 1 - bucket_idx;
304 let byte_idx = bit_from_msb / 8;
305 let bit_within = bit_from_msb % 8;
306 let mut out = *self_id.as_bytes();
307 if let Some(b) = out.get_mut(byte_idx) {
308 *b ^= 1u8 << (7 - bit_within);
309 }
310 if let Some(last) = out.last_mut() {
311 *last ^= salt;
312 }
313 NodeId::from_bytes(out)
314}
315
316#[cfg(test)]
317#[allow(
318 clippy::unwrap_used,
319 clippy::expect_used,
320 clippy::panic,
321 clippy::indexing_slicing
322)]
323mod tests;
324
325/// Property-based tests (issue #748).
326///
327/// The example-based `tests` module above pins specific scenarios; this module
328/// asserts the *invariants* hold under random `NodeId` insertion/removal
329/// sequences and arbitrary keyspace points — the class of structural bug
330/// (a bucket overflowing K, a peer in the wrong bucket, a mis-ordered
331/// `closest()`) that silently degrades lookup correctness but is easy to miss
332/// with hand-picked cases.
333///
334/// Note on terminology: issue #748 refers to a "k-bucket *split* invariant",
335/// but this table is the fixed-256-bucket variant (see the module docs) that
336/// **never splits**. The corresponding invariants tested here are deterministic
337/// bucket *placement* by XOR-prefix length and per-bucket fill `<= K_BUCKET_SIZE`
338/// enforced by LRU eviction.
339#[cfg(test)]
340#[allow(
341 clippy::unwrap_used,
342 clippy::expect_used,
343 clippy::panic,
344 clippy::indexing_slicing
345)]
346mod prop_tests;