dig_dht/record.rs
1//! [`ProviderRecord`] — the value the DHT stores: "peer P holds content C, reachable at these
2//! addresses, until this expiry" — plus the [`CandidateAddr`] address shape it carries.
3//!
4//! A provider record is what `announce_provider` PUTs and `find_providers` returns. It binds a
5//! **content key** (the [`ContentId`](crate::ContentId) hashed into the keyspace) to the
6//! **`peer_id`** of a node that holds it, together with candidate addresses so the finder can then
7//! open a dig-nat connection and fetch over the L7 peer RPC. Records are **TTL'd** (`expires_at`)
8//! and **republished** by the holder before expiry, so stale providers age out of the DHT
9//! automatically — a Kademlia provider record is soft state, not a permanent entry.
10//!
11//! The [`CandidateAddr`] `{ host, port, kind }` and the `kind` tokens are byte-compatible with the
12//! L7 peer-network `dig.getPeers` `addresses[]` shape (§7), so a record's addresses drop straight
13//! into a `PeerTarget` for [`dig_nat::connect`].
14
15use std::net::{IpAddr, SocketAddr};
16
17use dig_ip::Family;
18use serde::{Deserialize, Serialize};
19
20use dig_nat::PeerId;
21
22/// How a candidate address was learned — the L7 `dig.getPeers` `addresses[].kind` tokens (§7). The
23/// lowercase serde spelling is the frozen wire form; the ordering is most-direct-first (a dialer
24/// picks the lowest-rank dialable candidate).
25#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
26#[serde(rename_all = "lowercase")]
27pub enum AddressKind {
28 /// Advertised/observed directly reachable address (publicly routable or port-forwarded).
29 Direct,
30 /// A UPnP / NAT-PMP / PCP-mapped external address.
31 Mapped,
32 /// A STUN-discovered public reflexive address.
33 Reflexive,
34 /// Reachable through the relay (no direct candidate yet).
35 Relay,
36}
37
38impl AddressKind {
39 /// Most-direct-first rank (lower is more direct) — mirrors the dialer's candidate preference.
40 pub fn rank(self) -> u8 {
41 match self {
42 AddressKind::Direct => 0,
43 AddressKind::Mapped => 1,
44 AddressKind::Reflexive => 2,
45 AddressKind::Relay => 3,
46 }
47 }
48
49 /// Whether an address of this kind can be dialed directly (everything but a bare relay marker).
50 pub fn is_dialable(self) -> bool {
51 !matches!(self, AddressKind::Relay)
52 }
53}
54
55/// One candidate address for a provider: `{ host, port, kind }` (L7 `dig.getPeers` §7). The finder
56/// dials these (most-direct-first) via [`dig_nat::connect`] to reach the provider.
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
58pub struct CandidateAddr {
59 /// IPv4/IPv6 literal or hostname.
60 pub host: String,
61 /// P2P port.
62 pub port: u16,
63 /// How this address was learned.
64 pub kind: AddressKind,
65}
66
67impl CandidateAddr {
68 /// A directly-dialable candidate (public / port-forwarded / discovered).
69 pub fn direct(host: impl Into<String>, port: u16) -> Self {
70 CandidateAddr {
71 host: host.into(),
72 port,
73 kind: AddressKind::Direct,
74 }
75 }
76
77 /// A relay-only marker (no direct address; reach via the relay / a brokered hole punch).
78 pub fn relay_marker() -> Self {
79 CandidateAddr {
80 host: String::new(),
81 port: 0,
82 kind: AddressKind::Relay,
83 }
84 }
85
86 /// The address-family half of the sort key, derived from [`dig_ip::Family`] — the ecosystem's
87 /// single source of truth for the IPv6-first / IPv4-fallback rule (CLAUDE.md §5.2). `0` for a
88 /// genuine IPv6 literal, `1` for an IPv4 literal (including an IPv4-mapped IPv6 address, which
89 /// [`Family::of`] correctly classifies as V4 — it is IPv4 reachability) OR a hostname (which
90 /// parses as neither and falls back with IPv4). Deriving the family here, rather than
91 /// hand-rolling an `is_ipv6` check, keeps dig-dht from drifting off the canonical contract.
92 fn family_rank(&self) -> u8 {
93 let family = self
94 .host
95 .parse::<IpAddr>()
96 .ok()
97 .map(|ip| Family::of(&SocketAddr::new(ip, self.port)));
98 match family {
99 Some(Family::V6) => 0,
100 Some(Family::V4) | None => 1,
101 }
102 }
103
104 /// Sort key for IPv6-first, then most-direct-first ordering: `(family_rank, kind_rank)`. The
105 /// family half comes from [`dig_ip::Family`] (see [`family_rank`](Self::family_rank)); the
106 /// dht-specific directness tiebreak stays [`AddressKind::rank`], so within one family the most
107 /// direct candidate sorts first.
108 fn family_then_kind_rank(&self) -> (u8, u8) {
109 (self.family_rank(), self.kind.rank())
110 }
111}
112
113/// Sort `addresses` **IPv6-first, then by [`AddressKind::rank`]** — the ecosystem-wide IPv6-first,
114/// IPv4-fallback rule for peer communication. Used by both [`ProviderRecord::new`] and
115/// [`crate::routing::Contact::new`] so provider and routing-table address lists share one ordering
116/// policy. This only reorders the list; the wire shape of each [`CandidateAddr`] is unchanged.
117pub(crate) fn sort_addresses_ipv6_first(addresses: &mut [CandidateAddr]) {
118 addresses.sort_by_key(CandidateAddr::family_then_kind_rank);
119}
120
121/// Maximum [`CandidateAddr`] entries kept per [`ProviderRecord`] / [`crate::routing::Contact`].
122///
123/// A record/contact carries candidate addresses so a finder can dial the holder; nothing on the
124/// wire or decode path previously bounded how many a single record could carry (only the overall
125/// 256 KiB frame did — [`crate::wire::MAX_FRAMED_BODY`]), so one frame could smuggle thousands of
126/// addresses that the victim would store, fold into its routing table, AND re-serve (cloned) to
127/// every querying peer — memory inflation plus bandwidth amplification (SPEC §5.5, §14). Eight is
128/// generous headroom over the four [`AddressKind`] variants (a conforming producer emits at most
129/// one address per kind per family) while remaining a small, cheap-to-clone constant.
130pub const MAX_ADDRESSES_PER_RECORD: usize = 8;
131
132/// Sort `addresses` **IPv6-first-then-rank** (see [`sort_addresses_ipv6_first`]) and then truncate
133/// to [`MAX_ADDRESSES_PER_RECORD`], so the most-preferred candidates are the ones kept when a list
134/// exceeds the cap. This is the one admission point both the constructors ([`ProviderRecord::new`],
135/// [`crate::routing::Contact::new`]) and the wire-decode boundary (`handle_request_from`'s
136/// `AddProvider` arm, and contacts folded in from lookup responses) MUST call before accepting an
137/// address list from any source that did not already go through it — a `ProviderRecord` /
138/// `Contact` deserialized directly from the wire bypasses the constructors entirely (their fields
139/// are public), so capping only in `new` would not close the untrusted-input path.
140pub(crate) fn sort_and_cap_addresses(addresses: &mut Vec<CandidateAddr>) {
141 sort_addresses_ipv6_first(addresses);
142 addresses.truncate(MAX_ADDRESSES_PER_RECORD);
143}
144
145/// The DHT's stored value: peer `provider_peer_id` holds the content whose key is `content_key`,
146/// reachable at `addresses`, until `expires_at`.
147///
148/// - `content_key` is the 64-hex [`Key`](crate::Key) the content id hashed to — the DHT stores by
149/// key, not by the (larger, granularity-tagged) content id, so a record is compact and the store
150/// is a pure key→providers map.
151/// - `provider_peer_id` is the 64-hex `peer_id` of the holder; a finder builds a `PeerTarget` from
152/// it plus `addresses` and connects via dig-nat.
153/// - `expires_at` is absolute Unix seconds; a record past its expiry is treated as absent and GC'd.
154/// The holder republishes (a fresh record with a new `expires_at`) before expiry to stay findable.
155#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
156pub struct ProviderRecord {
157 /// The content key (64-hex) this record provides for — the [`Key`](crate::Key) a content id
158 /// hashed to.
159 pub content_key: String,
160 /// The holder's `peer_id` (64-hex).
161 pub provider_peer_id: String,
162 /// Candidate addresses to reach the holder. Ordered IPv6-first, then most-direct-first by
163 /// [`AddressKind::rank`] when built via [`ProviderRecord::new`]; a record deserialized directly
164 /// from the wire (bypassing `new`) is not guaranteed sorted, so a consumer that cannot assume a
165 /// conforming producer should still sort defensively.
166 pub addresses: Vec<CandidateAddr>,
167 /// Absolute expiry (Unix seconds). A record at/after this time is stale.
168 pub expires_at: u64,
169}
170
171impl ProviderRecord {
172 /// Build a record: peer `provider` holds `content_key`, reachable at `addresses`, until
173 /// `expires_at` (absolute Unix seconds).
174 pub fn new(
175 content_key: &crate::key::Key,
176 provider: &PeerId,
177 mut addresses: Vec<CandidateAddr>,
178 expires_at: u64,
179 ) -> Self {
180 sort_and_cap_addresses(&mut addresses);
181 ProviderRecord {
182 content_key: content_key.to_hex(),
183 provider_peer_id: provider.to_hex(),
184 addresses,
185 expires_at,
186 }
187 }
188
189 /// The provider's `peer_id` decoded from the 64-hex field, or `None` if malformed.
190 pub fn provider_peer_id(&self) -> Option<PeerId> {
191 PeerId::from_hex(&self.provider_peer_id)
192 }
193
194 /// Whether this record is expired at `now` (Unix seconds) — stale records are dropped on read.
195 pub fn is_expired(&self, now: u64) -> bool {
196 now >= self.expires_at
197 }
198
199 /// The IPv6-preferred, most-direct dialable candidate address, if any — the address a finder
200 /// dials first. `addresses` is already IPv6-first-then-rank sorted (`sort_addresses_ipv6_first`),
201 /// so this is simply the first dialable entry.
202 pub fn best_address(&self) -> Option<&CandidateAddr> {
203 self.addresses.iter().find(|a| a.kind.is_dialable())
204 }
205}
206
207#[cfg(test)]
208mod tests {
209 use super::*;
210 use crate::key::Key;
211
212 fn pid(b: u8) -> PeerId {
213 PeerId::from_bytes([b; 32])
214 }
215
216 #[test]
217 fn record_round_trips_through_json() {
218 let key = Key::from_bytes([0xAB; 32]);
219 let rec = ProviderRecord::new(
220 &key,
221 &pid(0x07),
222 vec![CandidateAddr::direct("203.0.113.7", 9444)],
223 1_000,
224 );
225 let json = serde_json::to_string(&rec).unwrap();
226 let back: ProviderRecord = serde_json::from_str(&json).unwrap();
227 assert_eq!(rec, back);
228 assert_eq!(back.provider_peer_id().unwrap(), pid(0x07));
229 assert_eq!(back.content_key, key.to_hex());
230 }
231
232 #[test]
233 fn ttl_expiry() {
234 let rec = ProviderRecord::new(&Key::from_bytes([0u8; 32]), &pid(1), vec![], 100);
235 assert!(!rec.is_expired(99));
236 assert!(rec.is_expired(100));
237 assert!(rec.is_expired(101));
238 }
239
240 #[test]
241 fn address_kind_wire_tokens_are_lowercase() {
242 assert_eq!(
243 serde_json::to_string(&AddressKind::Direct).unwrap(),
244 "\"direct\""
245 );
246 assert_eq!(
247 serde_json::to_string(&AddressKind::Reflexive).unwrap(),
248 "\"reflexive\""
249 );
250 assert_eq!(
251 serde_json::to_string(&AddressKind::Mapped).unwrap(),
252 "\"mapped\""
253 );
254 assert_eq!(
255 serde_json::to_string(&AddressKind::Relay).unwrap(),
256 "\"relay\""
257 );
258 }
259
260 #[test]
261 fn best_address_prefers_most_direct() {
262 let key = Key::from_bytes([0u8; 32]);
263 let rec = ProviderRecord::new(
264 &key,
265 &pid(1),
266 vec![
267 CandidateAddr {
268 host: "r".into(),
269 port: 1,
270 kind: AddressKind::Reflexive,
271 },
272 CandidateAddr::direct("d", 2),
273 CandidateAddr::relay_marker(),
274 ],
275 10,
276 );
277 assert_eq!(rec.best_address().unwrap().kind, AddressKind::Direct);
278 }
279
280 #[test]
281 fn best_address_none_when_only_relay() {
282 let key = Key::from_bytes([0u8; 32]);
283 let rec = ProviderRecord::new(&key, &pid(1), vec![CandidateAddr::relay_marker()], 10);
284 assert!(rec.best_address().is_none());
285 }
286
287 #[test]
288 fn address_rank_ordering() {
289 assert!(AddressKind::Direct.rank() < AddressKind::Mapped.rank());
290 assert!(AddressKind::Mapped.rank() < AddressKind::Reflexive.rank());
291 assert!(AddressKind::Reflexive.rank() < AddressKind::Relay.rank());
292 assert!(!AddressKind::Relay.is_dialable());
293 assert!(AddressKind::Direct.is_dialable());
294 }
295
296 #[test]
297 fn provider_record_new_sorts_addresses_ipv6_first() {
298 // Fed in IPv4-first order; the stored list must come out IPv6-first, then by rank.
299 let key = Key::from_bytes([0u8; 32]);
300 let rec = ProviderRecord::new(
301 &key,
302 &pid(1),
303 vec![
304 CandidateAddr::direct("203.0.113.7", 9444), // IPv4 direct
305 CandidateAddr::direct("2001:db8::1", 9444), // IPv6 direct
306 CandidateAddr {
307 host: "198.51.100.2".into(),
308 port: 1,
309 kind: AddressKind::Reflexive,
310 }, // IPv4 reflexive
311 CandidateAddr {
312 host: "2001:db8::2".into(),
313 port: 1,
314 kind: AddressKind::Reflexive,
315 }, // IPv6 reflexive
316 ],
317 10,
318 );
319 let hosts: Vec<&str> = rec.addresses.iter().map(|a| a.host.as_str()).collect();
320 assert_eq!(
321 hosts,
322 vec!["2001:db8::1", "2001:db8::2", "203.0.113.7", "198.51.100.2"],
323 "addresses must be IPv6-first, then ranked by AddressKind"
324 );
325 }
326
327 #[test]
328 fn family_key_derives_from_dig_ip_family() {
329 // The FAMILY half of the sort key comes from `dig_ip::Family`, the single ecosystem source
330 // of truth — not a hand-rolled `is_ipv6` heuristic. The load-bearing proof is the
331 // IPv4-mapped IPv6 case: `dig_ip::Family::of` classifies `::ffff:a.b.c.d` as V4 (it is IPv4
332 // reachability), so it must sort with IPv4, AFTER a genuine IPv6 address of the same kind. A
333 // `host.parse::<IpAddr>()`-based family key would have (wrongly) treated it as IPv6.
334 let key = Key::from_bytes([0u8; 32]);
335 let rec = ProviderRecord::new(
336 &key,
337 &pid(1),
338 vec![
339 CandidateAddr::direct("::ffff:203.0.113.9", 9444), // IPv4-mapped → V4 per dig-ip
340 CandidateAddr::direct("2001:db8::1", 9444), // genuine IPv6 → V6
341 ],
342 10,
343 );
344 let hosts: Vec<&str> = rec.addresses.iter().map(|a| a.host.as_str()).collect();
345 assert_eq!(
346 hosts,
347 vec!["2001:db8::1", "::ffff:203.0.113.9"],
348 "an IPv4-mapped IPv6 address must sort as V4 (dig_ip::Family), after a genuine IPv6"
349 );
350 }
351
352 #[test]
353 fn directness_kind_rank_preserved_as_tiebreak_within_a_family() {
354 // Within ONE address family the dht-specific most-direct-first `AddressKind::rank` tiebreak
355 // MUST survive the migration to dig-ip family keying: same family, different directness →
356 // Direct before Mapped before Reflexive.
357 let key = Key::from_bytes([0u8; 32]);
358 let rec = ProviderRecord::new(
359 &key,
360 &pid(1),
361 vec![
362 CandidateAddr {
363 host: "2001:db8::3".into(),
364 port: 1,
365 kind: AddressKind::Reflexive,
366 },
367 CandidateAddr {
368 host: "2001:db8::2".into(),
369 port: 1,
370 kind: AddressKind::Mapped,
371 },
372 CandidateAddr::direct("2001:db8::1", 9444),
373 ],
374 10,
375 );
376 let hosts: Vec<&str> = rec.addresses.iter().map(|a| a.host.as_str()).collect();
377 assert_eq!(
378 hosts,
379 vec!["2001:db8::1", "2001:db8::2", "2001:db8::3"],
380 "within one family, addresses must stay ordered by AddressKind::rank (most-direct first)"
381 );
382 }
383
384 #[test]
385 fn best_address_prefers_ipv6_over_ipv4_at_same_rank() {
386 let key = Key::from_bytes([0u8; 32]);
387 let rec = ProviderRecord::new(
388 &key,
389 &pid(1),
390 vec![
391 CandidateAddr::direct("203.0.113.7", 9444), // IPv4 direct, fed first
392 CandidateAddr::direct("2001:db8::1", 9444), // IPv6 direct, fed second
393 ],
394 10,
395 );
396 assert_eq!(rec.best_address().unwrap().host, "2001:db8::1");
397 }
398
399 // ---- Address-list cap (MEDIUM: no cap on addresses[], SECURITY_AUDIT_P2P.md #179) ----
400
401 #[test]
402 fn provider_record_new_caps_addresses_at_the_constant() {
403 // Feed far more than the cap — a hostile/misconfigured caller must never make a
404 // constructed record carry an unbounded address list.
405 let key = Key::from_bytes([0u8; 32]);
406 let many: Vec<CandidateAddr> = (0..1000)
407 .map(|i| CandidateAddr::direct(format!("203.0.113.{}", i % 255), 9444))
408 .collect();
409 let rec = ProviderRecord::new(&key, &pid(1), many, 10);
410 assert_eq!(rec.addresses.len(), MAX_ADDRESSES_PER_RECORD);
411 }
412
413 #[test]
414 fn provider_record_new_cap_keeps_most_preferred_after_sort() {
415 // The cap must apply AFTER the IPv6-first-then-rank sort, so truncation drops the LEAST
416 // preferred candidates, not an arbitrary prefix of the input order.
417 let key = Key::from_bytes([0u8; 32]);
418 let mut addrs: Vec<CandidateAddr> = Vec::new();
419 // One preferred IPv6 direct address that must survive the cap...
420 addrs.push(CandidateAddr::direct("2001:db8::1", 9444));
421 // ...buried behind far more than the cap worth of low-preference IPv4 relay markers.
422 for i in 0..1000u32 {
423 addrs.push(CandidateAddr {
424 host: format!("198.51.100.{}", i % 255),
425 port: 1,
426 kind: AddressKind::Relay,
427 });
428 }
429 let rec = ProviderRecord::new(&key, &pid(1), addrs, 10);
430 assert_eq!(rec.addresses.len(), MAX_ADDRESSES_PER_RECORD);
431 assert_eq!(
432 rec.addresses[0].host, "2001:db8::1",
433 "the single most-preferred (IPv6 direct) candidate must survive truncation"
434 );
435 }
436}