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