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):
88 ///
89 /// - `0` — a genuine IPv6 literal (tried first);
90 /// - `1` — an IPv4 literal, INCLUDING an IPv4-mapped IPv6 address, which [`Family::of`]
91 /// correctly classifies as V4 because it is IPv4 reachability (the fallback);
92 /// - `2` — a host that is not an IP literal at all. A DHT candidate is an *observed* socket
93 /// address, so a non-literal is a malformed or hostname-bearing record whose reachability this
94 /// crate cannot classify and does not resolve; it must never outrank a usable IPv4 literal.
95 ///
96 /// Deriving the family here, rather than hand-rolling an `is_ipv6` check, keeps dig-dht from
97 /// drifting off the canonical contract.
98 fn family_rank(&self) -> u8 {
99 let family = self
100 .host
101 .parse::<IpAddr>()
102 .ok()
103 .map(|ip| Family::of(&SocketAddr::new(ip, self.port)));
104 match family {
105 Some(Family::V6) => 0,
106 Some(Family::V4) => 1,
107 None => 2,
108 }
109 }
110
111 /// The identity of the ENDPOINT this candidate names, for deduplication: the parsed address in
112 /// canonical form plus the port, falling back to the raw host text when it is not an IP literal.
113 ///
114 /// Deduplicating on the raw `host` string would treat one address spelled several ways as several
115 /// dial targets — `2001:db8::1`, `2001:0db8::1`, `2001:db8:0:0:0:0:0:1` and `2001:DB8::1` are the
116 /// same host — which lets a padded record consume every slot of the dial set with a single
117 /// address. Parsing first collapses those spellings, and an IPv4-mapped IPv6 literal is reduced to
118 /// its IPv4 form so `::ffff:a.b.c.d` and `a.b.c.d` are recognised as one endpoint (consistent with
119 /// [`Family::of`] classifying both as IPv4 reachability).
120 fn dial_identity(&self) -> (String, u16) {
121 let host = match self.host.parse::<IpAddr>() {
122 Ok(IpAddr::V6(v6)) => v6
123 .to_ipv4_mapped()
124 .map(IpAddr::V4)
125 .unwrap_or(IpAddr::V6(v6))
126 .to_string(),
127 Ok(ip) => ip.to_string(),
128 Err(_) => self.host.clone(),
129 };
130 (host, self.port)
131 }
132
133 /// Whether this candidate is a genuine IPv6 literal — the tier that is tried FIRST and, being
134 /// the preferred tier, the one that can crowd every other out of a capped dial set.
135 fn is_ipv6_literal(&self) -> bool {
136 self.family_rank() == 0
137 }
138
139 /// Sort key for IPv6-first, then most-direct-first ordering: `(family_rank, kind_rank)`. The
140 /// family half comes from [`dig_ip::Family`] (see [`family_rank`](Self::family_rank)); the
141 /// dht-specific directness tiebreak stays [`AddressKind::rank`], so within one family the most
142 /// direct candidate sorts first.
143 fn family_then_kind_rank(&self) -> (u8, u8) {
144 (self.family_rank(), self.kind.rank())
145 }
146}
147
148/// Sort `addresses` **IPv6-first, then by [`AddressKind::rank`]** — the ecosystem-wide IPv6-first,
149/// IPv4-fallback rule for peer communication. Used by both [`ProviderRecord::new`] and
150/// [`crate::routing::Contact::new`] so provider and routing-table address lists share one ordering
151/// policy. This only reorders the list; the wire shape of each [`CandidateAddr`] is unchanged.
152pub(crate) fn sort_addresses_ipv6_first(addresses: &mut [CandidateAddr]) {
153 addresses.sort_by_key(CandidateAddr::family_then_kind_rank);
154}
155
156/// Maximum [`CandidateAddr`] entries kept per [`ProviderRecord`] / [`crate::routing::Contact`].
157///
158/// A record/contact carries candidate addresses so a finder can dial the holder; nothing on the
159/// wire or decode path previously bounded how many a single record could carry (only the overall
160/// 256 KiB frame did — [`crate::wire::MAX_FRAMED_BODY`]), so one frame could smuggle thousands of
161/// addresses that the victim would store, fold into its routing table, AND re-serve (cloned) to
162/// every querying peer — memory inflation plus bandwidth amplification (SPEC §5.5, §14). Eight is
163/// generous headroom over the four [`AddressKind`] variants (a conforming producer emits at most
164/// one address per kind per family) while remaining a small, cheap-to-clone constant.
165pub const MAX_ADDRESSES_PER_RECORD: usize = 8;
166
167/// Sort `addresses` **IPv6-first-then-rank** (see [`sort_addresses_ipv6_first`]) and then truncate
168/// to [`MAX_ADDRESSES_PER_RECORD`], so the most-preferred candidates are the ones kept when a list
169/// exceeds the cap. This is the one admission point both the constructors ([`ProviderRecord::new`],
170/// [`crate::routing::Contact::new`]) and the wire-decode boundary (`handle_request_from`'s
171/// `AddProvider` arm, and contacts folded in from lookup responses) MUST call before accepting an
172/// address list from any source that did not already go through it — a `ProviderRecord` /
173/// `Contact` deserialized directly from the wire bypasses the constructors entirely (their fields
174/// are public), so capping only in `new` would not close the untrusted-input path.
175pub(crate) fn sort_and_cap_addresses(addresses: &mut Vec<CandidateAddr>) {
176 sort_addresses_ipv6_first(addresses);
177 addresses.truncate(MAX_ADDRESSES_PER_RECORD);
178}
179
180/// Upper bound on how many candidates [`dial_candidates`] hands a dialer for ONE peer, so a record
181/// padded with addresses cannot turn a single holder into a connect storm. Byte-for-byte the same
182/// bound dig-download applies on its own dial path, so a consumer that adopts this iterator sees no
183/// change in attempt count.
184pub const MAX_DIAL_CANDIDATES: usize = 4;
185
186/// The dialable candidates of `addresses`, in **dial order**: IPv6 first, then IPv4, then anything
187/// unresolvable — deduped by `host:port` and capped at [`MAX_DIAL_CANDIDATES`].
188///
189/// This is the §5.2-compliant order (IPv6-first, IPv4-**fallback**) and the ONE place the DHT
190/// expresses it, so every consumer inherits it instead of re-deriving a ranking of its own. A dialer
191/// walks the WHOLE list and only reports failure once every candidate has been tried: in #836 a
192/// reader instead took a single address, tried one IPv6 literal, and gave up while a working IPv4
193/// candidate sat unused — v4 is the fallback, so a failed v6 attempt MUST fall through to it.
194///
195/// Relay markers are excluded (they are not directly dialable — reach those peers via the relay /
196/// a brokered punch). Unresolvable candidates are KEPT, last, on purpose: a dialer that walks them
197/// can report a concrete per-candidate reason instead of pretending the provider had no address.
198pub fn dial_candidates(addresses: &[CandidateAddr]) -> Vec<&CandidateAddr> {
199 let mut candidates: Vec<&CandidateAddr> =
200 addresses.iter().filter(|a| a.kind.is_dialable()).collect();
201 // Sorted defensively rather than trusting the stored order: the same ranking is applied when a
202 // list is constructed or deserialized, but `addresses` is a public field any caller may rewrite.
203 candidates.sort_by_key(|a| a.family_then_kind_rank());
204 let mut seen = std::collections::HashSet::new();
205 candidates.retain(|a| seen.insert(a.dial_identity()));
206 reserve_fallback_slot_and_cap(&mut candidates);
207 candidates
208}
209
210/// Truncate `candidates` to [`MAX_DIAL_CANDIDATES`] while KEEPING the fallback tier represented.
211///
212/// Truncating the family-sorted list outright would let the preferred tier fill the cap on its own: a
213/// holder advertising four or more IPv6 candidates would yield a dial set containing no IPv4 at all,
214/// so a dialer that faithfully walked every candidate it was given would STILL never reach the working
215/// address — precisely the #836 read-leg failure this iterator exists to prevent, and a violation of
216/// the rule that a failed IPv6 attempt must never mask a working IPv4 one. It needs no attacker: a
217/// dual-stack holder legitimately emits direct + mapped + reflexive IPv6 candidates, and an IPv6
218/// address with no working route is ordinary.
219///
220/// So when the cap would exclude EVERY non-IPv6 candidate and one exists, the least-preferred kept
221/// slot is given to the best non-IPv6 candidate. IPv6 still leads the list — the reservation costs one
222/// surplus IPv6 attempt, never the ordering.
223fn reserve_fallback_slot_and_cap(candidates: &mut Vec<&CandidateAddr>) {
224 if candidates.len() <= MAX_DIAL_CANDIDATES {
225 return;
226 }
227 let kept_excludes_every_fallback = candidates[..MAX_DIAL_CANDIDATES]
228 .iter()
229 .all(|a| a.is_ipv6_literal());
230 let fallback = kept_excludes_every_fallback
231 .then(|| candidates.iter().find(|a| !a.is_ipv6_literal()).copied())
232 .flatten();
233 match fallback {
234 Some(fallback) => {
235 candidates.truncate(MAX_DIAL_CANDIDATES - 1);
236 candidates.push(fallback);
237 }
238 None => candidates.truncate(MAX_DIAL_CANDIDATES),
239 }
240}
241
242/// serde hook applied to every `addresses` field ([`ProviderRecord`], [`crate::routing::Contact`]),
243/// so [`MAX_ADDRESSES_PER_RECORD`] holds **by construction** for any value that is deserialized —
244/// from a peer's wire frame, a config file, or a cached snapshot — and not only at the ingest call
245/// sites that remember to call [`sort_and_cap_addresses`] (§14). Deserialization is the ONE
246/// unavoidable gate every untrusted address list passes through; enforcing the bound there means a
247/// future ingest path cannot silently reintroduce an unbounded list.
248///
249/// It **bounds rather than rejects**: a list longer than the cap is sorted and truncated, never
250/// turned into a decode error. Rejecting would make a nonconforming (or simply older, looser)
251/// producer's record unparseable, which the store-format compatibility rule forbids — and would
252/// hand a peer an easy way to poison a whole frame. Sorting before truncating keeps the
253/// most-preferred candidates, so a hostile peer cannot bury the one reachable address behind filler.
254pub(crate) fn deserialize_capped_addresses<'de, D>(
255 deserializer: D,
256) -> Result<Vec<CandidateAddr>, D::Error>
257where
258 D: serde::Deserializer<'de>,
259{
260 let mut addresses = Vec::<CandidateAddr>::deserialize(deserializer)?;
261 sort_and_cap_addresses(&mut addresses);
262 Ok(addresses)
263}
264
265/// The DHT's stored value: peer `provider_peer_id` holds the content whose key is `content_key`,
266/// reachable at `addresses`, until `expires_at`.
267///
268/// - `content_key` is the 64-hex [`Key`](crate::Key) the content id hashed to — the DHT stores by
269/// key, not by the (larger, granularity-tagged) content id, so a record is compact and the store
270/// is a pure key→providers map.
271/// - `provider_peer_id` is the 64-hex `peer_id` of the holder; a finder builds a `PeerTarget` from
272/// it plus `addresses` and connects via dig-nat.
273/// - `expires_at` is absolute Unix seconds; a record past its expiry is treated as absent and GC'd.
274/// The holder republishes (a fresh record with a new `expires_at`) before expiry to stay findable.
275#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
276pub struct ProviderRecord {
277 /// The content key (64-hex) this record provides for — the [`Key`](crate::Key) a content id
278 /// hashed to.
279 pub content_key: String,
280 /// The holder's `peer_id` (64-hex).
281 pub provider_peer_id: String,
282 /// Candidate addresses to reach the holder, ordered IPv6-first then most-direct-first by
283 /// [`AddressKind::rank`] and bounded to [`MAX_ADDRESSES_PER_RECORD`] — held by BOTH
284 /// [`ProviderRecord::new`] and deserialization ([`deserialize_capped_addresses`]), so a record
285 /// off the wire carries the same guarantee as a constructed one.
286 #[serde(deserialize_with = "deserialize_capped_addresses")]
287 pub addresses: Vec<CandidateAddr>,
288 /// Absolute expiry (Unix seconds). A record at/after this time is stale.
289 pub expires_at: u64,
290}
291
292impl ProviderRecord {
293 /// Build a record: peer `provider` holds `content_key`, reachable at `addresses`, until
294 /// `expires_at` (absolute Unix seconds).
295 pub fn new(
296 content_key: &crate::key::Key,
297 provider: &PeerId,
298 mut addresses: Vec<CandidateAddr>,
299 expires_at: u64,
300 ) -> Self {
301 sort_and_cap_addresses(&mut addresses);
302 ProviderRecord {
303 content_key: content_key.to_hex(),
304 provider_peer_id: provider.to_hex(),
305 addresses,
306 expires_at,
307 }
308 }
309
310 /// The provider's `peer_id` decoded from the 64-hex field, or `None` if malformed.
311 pub fn provider_peer_id(&self) -> Option<PeerId> {
312 PeerId::from_hex(&self.provider_peer_id)
313 }
314
315 /// Whether this record is expired at `now` (Unix seconds) — stale records are dropped on read.
316 pub fn is_expired(&self, now: u64) -> bool {
317 now >= self.expires_at
318 }
319
320 /// The FIRST candidate only — the IPv6-preferred, most-direct dialable address, if any.
321 ///
322 /// **Prefer [`dial_candidates`](Self::dial_candidates) for dialing.** This returns one address,
323 /// so a caller that dials it and stops has made a single attempt and cannot fall back: an
324 /// unusable IPv6 candidate then masks a working IPv4 one, violating the IPv4-**fallback** half
325 /// of §5.2 (exactly the #836 read-leg failure). Use this only where a single representative
326 /// address is genuinely what is wanted — a log line, a display string, a metric label.
327 pub fn best_address(&self) -> Option<&CandidateAddr> {
328 self.addresses.iter().find(|a| a.kind.is_dialable())
329 }
330
331 /// This provider's dialable candidates in §5.2 dial order — see [`dial_candidates`] for the
332 /// ordering contract. Dial these in order, falling through on failure, before concluding the
333 /// holder is unreachable.
334 pub fn dial_candidates(&self) -> Vec<&CandidateAddr> {
335 dial_candidates(&self.addresses)
336 }
337}
338
339#[cfg(test)]
340mod tests {
341 use super::*;
342 use crate::key::Key;
343
344 fn pid(b: u8) -> PeerId {
345 PeerId::from_bytes([b; 32])
346 }
347
348 #[test]
349 fn record_round_trips_through_json() {
350 let key = Key::from_bytes([0xAB; 32]);
351 let rec = ProviderRecord::new(
352 &key,
353 &pid(0x07),
354 vec![CandidateAddr::direct("203.0.113.7", 9444)],
355 1_000,
356 );
357 let json = serde_json::to_string(&rec).unwrap();
358 let back: ProviderRecord = serde_json::from_str(&json).unwrap();
359 assert_eq!(rec, back);
360 assert_eq!(back.provider_peer_id().unwrap(), pid(0x07));
361 assert_eq!(back.content_key, key.to_hex());
362 }
363
364 #[test]
365 fn ttl_expiry() {
366 let rec = ProviderRecord::new(&Key::from_bytes([0u8; 32]), &pid(1), vec![], 100);
367 assert!(!rec.is_expired(99));
368 assert!(rec.is_expired(100));
369 assert!(rec.is_expired(101));
370 }
371
372 #[test]
373 fn address_kind_wire_tokens_are_lowercase() {
374 assert_eq!(
375 serde_json::to_string(&AddressKind::Direct).unwrap(),
376 "\"direct\""
377 );
378 assert_eq!(
379 serde_json::to_string(&AddressKind::Reflexive).unwrap(),
380 "\"reflexive\""
381 );
382 assert_eq!(
383 serde_json::to_string(&AddressKind::Mapped).unwrap(),
384 "\"mapped\""
385 );
386 assert_eq!(
387 serde_json::to_string(&AddressKind::Relay).unwrap(),
388 "\"relay\""
389 );
390 }
391
392 #[test]
393 fn best_address_prefers_most_direct() {
394 let key = Key::from_bytes([0u8; 32]);
395 let rec = ProviderRecord::new(
396 &key,
397 &pid(1),
398 vec![
399 CandidateAddr {
400 host: "r".into(),
401 port: 1,
402 kind: AddressKind::Reflexive,
403 },
404 CandidateAddr::direct("d", 2),
405 CandidateAddr::relay_marker(),
406 ],
407 10,
408 );
409 assert_eq!(rec.best_address().unwrap().kind, AddressKind::Direct);
410 }
411
412 #[test]
413 fn best_address_none_when_only_relay() {
414 let key = Key::from_bytes([0u8; 32]);
415 let rec = ProviderRecord::new(&key, &pid(1), vec![CandidateAddr::relay_marker()], 10);
416 assert!(rec.best_address().is_none());
417 }
418
419 #[test]
420 fn address_rank_ordering() {
421 assert!(AddressKind::Direct.rank() < AddressKind::Mapped.rank());
422 assert!(AddressKind::Mapped.rank() < AddressKind::Reflexive.rank());
423 assert!(AddressKind::Reflexive.rank() < AddressKind::Relay.rank());
424 assert!(!AddressKind::Relay.is_dialable());
425 assert!(AddressKind::Direct.is_dialable());
426 }
427
428 #[test]
429 fn provider_record_new_sorts_addresses_ipv6_first() {
430 // Fed in IPv4-first order; the stored list must come out IPv6-first, then by rank.
431 let key = Key::from_bytes([0u8; 32]);
432 let rec = ProviderRecord::new(
433 &key,
434 &pid(1),
435 vec![
436 CandidateAddr::direct("203.0.113.7", 9444), // IPv4 direct
437 CandidateAddr::direct("2001:db8::1", 9444), // IPv6 direct
438 CandidateAddr {
439 host: "198.51.100.2".into(),
440 port: 1,
441 kind: AddressKind::Reflexive,
442 }, // IPv4 reflexive
443 CandidateAddr {
444 host: "2001:db8::2".into(),
445 port: 1,
446 kind: AddressKind::Reflexive,
447 }, // IPv6 reflexive
448 ],
449 10,
450 );
451 let hosts: Vec<&str> = rec.addresses.iter().map(|a| a.host.as_str()).collect();
452 assert_eq!(
453 hosts,
454 vec!["2001:db8::1", "2001:db8::2", "203.0.113.7", "198.51.100.2"],
455 "addresses must be IPv6-first, then ranked by AddressKind"
456 );
457 }
458
459 #[test]
460 fn family_key_derives_from_dig_ip_family() {
461 // The FAMILY half of the sort key comes from `dig_ip::Family`, the single ecosystem source
462 // of truth — not a hand-rolled `is_ipv6` heuristic. The load-bearing proof is the
463 // IPv4-mapped IPv6 case: `dig_ip::Family::of` classifies `::ffff:a.b.c.d` as V4 (it is IPv4
464 // reachability), so it must sort with IPv4, AFTER a genuine IPv6 address of the same kind. A
465 // `host.parse::<IpAddr>()`-based family key would have (wrongly) treated it as IPv6.
466 let key = Key::from_bytes([0u8; 32]);
467 let rec = ProviderRecord::new(
468 &key,
469 &pid(1),
470 vec![
471 CandidateAddr::direct("::ffff:203.0.113.9", 9444), // IPv4-mapped → V4 per dig-ip
472 CandidateAddr::direct("2001:db8::1", 9444), // genuine IPv6 → V6
473 ],
474 10,
475 );
476 let hosts: Vec<&str> = rec.addresses.iter().map(|a| a.host.as_str()).collect();
477 assert_eq!(
478 hosts,
479 vec!["2001:db8::1", "::ffff:203.0.113.9"],
480 "an IPv4-mapped IPv6 address must sort as V4 (dig_ip::Family), after a genuine IPv6"
481 );
482 }
483
484 #[test]
485 fn directness_kind_rank_preserved_as_tiebreak_within_a_family() {
486 // Within ONE address family the dht-specific most-direct-first `AddressKind::rank` tiebreak
487 // MUST survive the migration to dig-ip family keying: same family, different directness →
488 // Direct before Mapped before Reflexive.
489 let key = Key::from_bytes([0u8; 32]);
490 let rec = ProviderRecord::new(
491 &key,
492 &pid(1),
493 vec![
494 CandidateAddr {
495 host: "2001:db8::3".into(),
496 port: 1,
497 kind: AddressKind::Reflexive,
498 },
499 CandidateAddr {
500 host: "2001:db8::2".into(),
501 port: 1,
502 kind: AddressKind::Mapped,
503 },
504 CandidateAddr::direct("2001:db8::1", 9444),
505 ],
506 10,
507 );
508 let hosts: Vec<&str> = rec.addresses.iter().map(|a| a.host.as_str()).collect();
509 assert_eq!(
510 hosts,
511 vec!["2001:db8::1", "2001:db8::2", "2001:db8::3"],
512 "within one family, addresses must stay ordered by AddressKind::rank (most-direct first)"
513 );
514 }
515
516 #[test]
517 fn best_address_prefers_ipv6_over_ipv4_at_same_rank() {
518 let key = Key::from_bytes([0u8; 32]);
519 let rec = ProviderRecord::new(
520 &key,
521 &pid(1),
522 vec![
523 CandidateAddr::direct("203.0.113.7", 9444), // IPv4 direct, fed first
524 CandidateAddr::direct("2001:db8::1", 9444), // IPv6 direct, fed second
525 ],
526 10,
527 );
528 assert_eq!(rec.best_address().unwrap().host, "2001:db8::1");
529 }
530
531 // ---- Address-list cap (MEDIUM: no cap on addresses[], SECURITY_AUDIT_P2P.md #179) ----
532
533 #[test]
534 fn provider_record_new_caps_addresses_at_the_constant() {
535 // Feed far more than the cap — a hostile/misconfigured caller must never make a
536 // constructed record carry an unbounded address list.
537 let key = Key::from_bytes([0u8; 32]);
538 let many: Vec<CandidateAddr> = (0..1000)
539 .map(|i| CandidateAddr::direct(format!("203.0.113.{}", i % 255), 9444))
540 .collect();
541 let rec = ProviderRecord::new(&key, &pid(1), many, 10);
542 assert_eq!(rec.addresses.len(), MAX_ADDRESSES_PER_RECORD);
543 }
544
545 #[test]
546 fn provider_record_new_cap_keeps_most_preferred_after_sort() {
547 // The cap must apply AFTER the IPv6-first-then-rank sort, so truncation drops the LEAST
548 // preferred candidates, not an arbitrary prefix of the input order.
549 let key = Key::from_bytes([0u8; 32]);
550 let mut addrs: Vec<CandidateAddr> = Vec::new();
551 // One preferred IPv6 direct address that must survive the cap...
552 addrs.push(CandidateAddr::direct("2001:db8::1", 9444));
553 // ...buried behind far more than the cap worth of low-preference IPv4 relay markers.
554 for i in 0..1000u32 {
555 addrs.push(CandidateAddr {
556 host: format!("198.51.100.{}", i % 255),
557 port: 1,
558 kind: AddressKind::Relay,
559 });
560 }
561 let rec = ProviderRecord::new(&key, &pid(1), addrs, 10);
562 assert_eq!(rec.addresses.len(), MAX_ADDRESSES_PER_RECORD);
563 assert_eq!(
564 rec.addresses[0].host, "2001:db8::1",
565 "the single most-preferred (IPv6 direct) candidate must survive truncation"
566 );
567 }
568
569 // ---- Deserialization-time address bound (#1514) ----
570
571 /// Build the JSON of a record carrying `n` addresses — the shape a hostile peer frames on the
572 /// wire, bypassing `ProviderRecord::new` entirely (its fields are public).
573 fn record_json_with_addresses(n: usize) -> String {
574 let addrs: Vec<String> = (0..n)
575 .map(|i| {
576 format!(
577 r#"{{"host":"198.51.100.{}","port":1,"kind":"relay"}}"#,
578 i % 255
579 )
580 })
581 .collect();
582 format!(
583 r#"{{"content_key":"{}","provider_peer_id":"{}","addresses":[{}],"expires_at":1}}"#,
584 "aa".repeat(32),
585 "bb".repeat(32),
586 addrs.join(",")
587 )
588 }
589
590 #[test]
591 fn deserialization_bounds_the_address_count() {
592 // #1514: the cap must hold BY CONSTRUCTION at the decode boundary, not only at the ingest
593 // call sites that remember to call `sort_and_cap_addresses`. Stated over the CLASS: no
594 // deserialized record, from any source, ever carries more than the cap.
595 let rec: ProviderRecord = serde_json::from_str(&record_json_with_addresses(1000)).unwrap();
596 assert_eq!(rec.addresses.len(), MAX_ADDRESSES_PER_RECORD);
597 }
598
599 #[test]
600 fn deserialization_bound_is_one_off_exact() {
601 // The one-off variant: exactly the cap survives untouched; exactly one more is bounded.
602 let at_cap: ProviderRecord =
603 serde_json::from_str(&record_json_with_addresses(MAX_ADDRESSES_PER_RECORD)).unwrap();
604 assert_eq!(at_cap.addresses.len(), MAX_ADDRESSES_PER_RECORD);
605 let over_by_one: ProviderRecord =
606 serde_json::from_str(&record_json_with_addresses(MAX_ADDRESSES_PER_RECORD + 1))
607 .unwrap();
608 assert_eq!(over_by_one.addresses.len(), MAX_ADDRESSES_PER_RECORD);
609 }
610
611 #[test]
612 fn deserialization_keeps_the_most_preferred_addresses() {
613 // Bounding must drop the LEAST preferred candidates, so a hostile peer cannot bury the one
614 // genuinely reachable address behind a wall of filler and have it truncated away.
615 let mut addrs: Vec<String> =
616 vec![r#"{"host":"2001:db8::1","port":9444,"kind":"direct"}"#.to_string()];
617 for i in 0..1000 {
618 addrs.push(format!(
619 r#"{{"host":"198.51.100.{}","port":1,"kind":"relay"}}"#,
620 i % 255
621 ));
622 }
623 // The preferred candidate sits LAST in the wire order, so a naive prefix-truncation would
624 // discard exactly the address that matters.
625 addrs.rotate_left(1);
626 let json = format!(
627 r#"{{"content_key":"{}","provider_peer_id":"{}","addresses":[{}],"expires_at":1}}"#,
628 "aa".repeat(32),
629 "bb".repeat(32),
630 addrs.join(",")
631 );
632 let rec: ProviderRecord = serde_json::from_str(&json).unwrap();
633 assert_eq!(rec.addresses.len(), MAX_ADDRESSES_PER_RECORD);
634 assert_eq!(
635 rec.addresses[0].host, "2001:db8::1",
636 "the most-preferred candidate must survive the bound regardless of wire position"
637 );
638 }
639
640 // ---- Ordered dial candidates (#1594) ----
641
642 fn record_with(addresses: Vec<CandidateAddr>) -> ProviderRecord {
643 ProviderRecord::new(&Key::from_bytes([0u8; 32]), &pid(1), addresses, 10)
644 }
645
646 #[test]
647 fn dial_candidates_order_v6_then_v4_then_unresolvable() {
648 let rec = record_with(vec![
649 CandidateAddr::direct("not-a-literal", 9444),
650 CandidateAddr::direct("203.0.113.7", 9444),
651 CandidateAddr::direct("2001:db8::1", 9444),
652 ]);
653 let hosts: Vec<&str> = rec
654 .dial_candidates()
655 .iter()
656 .map(|a| a.host.as_str())
657 .collect();
658 assert_eq!(
659 hosts,
660 vec!["2001:db8::1", "203.0.113.7", "not-a-literal"],
661 "dial order is IPv6, then IPv4, then anything unresolvable (§5.2)"
662 );
663 }
664
665 #[test]
666 fn dial_candidates_keep_the_ipv4_fallback_behind_an_ipv6_candidate() {
667 // The #836 failure this exists to prevent: a probe took `best_address()` alone, tried ONE
668 // IPv6 literal, and gave up while a working IPv4 candidate sat unused. IPv4 is the FALLBACK
669 // (§5.2), so it MUST still be present, after the v6 candidate, for a dialer to walk to.
670 let rec = record_with(vec![
671 CandidateAddr::direct("2001:db8::1", 9444),
672 CandidateAddr::direct("172.31.79.22", 9444),
673 ]);
674 let candidates = rec.dial_candidates();
675 assert_eq!(candidates.len(), 2, "the fallback must not be dropped");
676 assert_eq!(candidates[0].host, "2001:db8::1");
677 assert_eq!(candidates[1].host, "172.31.79.22");
678 }
679
680 #[test]
681 fn dial_candidates_treat_v4_mapped_v6_as_ipv4() {
682 // Canonical IPv4-in-IPv6 rule: `::ffff:a.b.c.d` is IPv4 REACHABILITY, so it must order with
683 // IPv4 — after a genuine IPv6 candidate. This is the one case where a hand-rolled
684 // `is_ipv6`-style check silently disagrees with `dig_ip::Family`.
685 let rec = record_with(vec![
686 CandidateAddr::direct("::ffff:203.0.113.9", 9444),
687 CandidateAddr::direct("2001:db8::1", 9444),
688 ]);
689 let hosts: Vec<&str> = rec
690 .dial_candidates()
691 .iter()
692 .map(|a| a.host.as_str())
693 .collect();
694 assert_eq!(hosts, vec!["2001:db8::1", "::ffff:203.0.113.9"]);
695 }
696
697 #[test]
698 fn dial_candidates_exclude_relay_markers() {
699 let rec = record_with(vec![
700 CandidateAddr::relay_marker(),
701 CandidateAddr::direct("2001:db8::1", 9444),
702 ]);
703 let candidates = rec.dial_candidates();
704 assert_eq!(
705 candidates.len(),
706 1,
707 "a relay marker is not directly dialable"
708 );
709 assert_eq!(candidates[0].host, "2001:db8::1");
710 }
711
712 #[test]
713 fn dial_candidates_are_bounded_and_deduped() {
714 // A record may legitimately carry up to MAX_ADDRESSES_PER_RECORD candidates; a dialer must
715 // not turn one provider into an unbounded connect storm, and must not waste an attempt
716 // re-dialing the same host:port twice.
717 let mut addresses = vec![CandidateAddr::direct("2001:db8::1", 9444); 3];
718 addresses.extend((0..5).map(|i| CandidateAddr::direct(format!("10.0.0.{i}"), 9444)));
719 let rec = record_with(addresses);
720 let candidates = rec.dial_candidates();
721 assert_eq!(candidates.len(), MAX_DIAL_CANDIDATES);
722 assert_eq!(
723 candidates
724 .iter()
725 .filter(|a| a.host == "2001:db8::1")
726 .count(),
727 1,
728 "a repeated host:port contributes exactly one dial attempt"
729 );
730 }
731
732 #[test]
733 fn dial_candidates_of_a_relay_only_record_are_empty() {
734 let rec = record_with(vec![CandidateAddr::relay_marker()]);
735 assert!(rec.dial_candidates().is_empty());
736 }
737
738 #[test]
739 fn unresolvable_host_sorts_after_an_ipv4_literal_in_the_stored_order() {
740 // The stored order and the dial order share ONE ranking policy, so a hostname (which is not
741 // reachability the DHT can classify) must never outrank a usable IPv4 literal anywhere.
742 let rec = record_with(vec![
743 CandidateAddr::direct("not-a-literal", 1),
744 CandidateAddr::direct("203.0.113.7", 1),
745 ]);
746 let hosts: Vec<&str> = rec.addresses.iter().map(|a| a.host.as_str()).collect();
747 assert_eq!(hosts, vec!["203.0.113.7", "not-a-literal"]);
748 }
749
750 #[test]
751 fn dial_candidates_reserve_a_slot_for_the_ipv4_fallback() {
752 // #836 again, one layer down: truncating to MAX_DIAL_CANDIDATES *after* the family sort means
753 // a record carrying four or more IPv6 candidates yields a dial set with ZERO IPv4 — so a
754 // dialer walking every candidate it is given still never reaches the working address. That
755 // contradicts the SPEC 5.5 MUST that a failed IPv6 attempt never masks a working IPv4 one.
756 // A dual-stack holder legitimately emits direct + mapped + reflexive v6, so this is reachable
757 // without an attacker; an IPv6 address with no working route is the common AWS case.
758 let rec = record_with(vec![
759 CandidateAddr::direct("2001:db8::1", 9444),
760 CandidateAddr::direct("2001:db8::2", 9444),
761 CandidateAddr::direct("2001:db8::3", 9444),
762 CandidateAddr::direct("2001:db8::4", 9444),
763 CandidateAddr::direct("203.0.113.7", 9444),
764 ]);
765 let candidates = rec.dial_candidates();
766 assert_eq!(candidates.len(), MAX_DIAL_CANDIDATES);
767 assert!(
768 candidates.iter().any(|a| a.host == "203.0.113.7"),
769 "the IPv4 fallback tier must keep a slot inside the cap, got {:?}",
770 candidates.iter().map(|a| &a.host).collect::<Vec<_>>()
771 );
772 assert_eq!(
773 candidates[0].host, "2001:db8::1",
774 "IPv6 still leads — the reservation costs the LEAST preferred v6 slot, not the order"
775 );
776 }
777
778 #[test]
779 fn dial_candidates_reserve_the_fallback_only_when_it_would_be_lost() {
780 // The one-off variant either side of the cap: at exactly the cap nothing is dropped and no
781 // reservation is needed, so a v4 that already fits must not be promoted out of order.
782 let rec = record_with(vec![
783 CandidateAddr::direct("2001:db8::1", 9444),
784 CandidateAddr::direct("2001:db8::2", 9444),
785 CandidateAddr::direct("2001:db8::3", 9444),
786 CandidateAddr::direct("203.0.113.7", 9444),
787 ]);
788 let hosts: Vec<&str> = rec
789 .dial_candidates()
790 .iter()
791 .map(|a| a.host.as_str())
792 .collect();
793 assert_eq!(
794 hosts,
795 vec!["2001:db8::1", "2001:db8::2", "2001:db8::3", "203.0.113.7"]
796 );
797 }
798
799 #[test]
800 fn dial_candidates_dedupe_equivalent_spellings_of_one_address() {
801 // Dedup on the RAW host string lets one address spelled four ways consume every slot, which
802 // is the fallback-starvation above with no distinct addresses at all. Equivalence is a
803 // property of the parsed IpAddr, not of the text.
804 let rec = record_with(vec![
805 CandidateAddr::direct("2001:db8::1", 9444),
806 CandidateAddr::direct("2001:0db8::1", 9444),
807 CandidateAddr::direct("2001:db8:0:0:0:0:0:1", 9444),
808 CandidateAddr::direct("2001:DB8::1", 9444),
809 CandidateAddr::direct("203.0.113.7", 9444),
810 ]);
811 let candidates = rec.dial_candidates();
812 assert_eq!(
813 candidates.len(),
814 2,
815 "four spellings of one IPv6 address are ONE dial attempt, got {:?}",
816 candidates.iter().map(|a| &a.host).collect::<Vec<_>>()
817 );
818 assert!(candidates.iter().any(|a| a.host == "203.0.113.7"));
819 }
820
821 #[test]
822 fn dial_candidates_treat_a_v4_mapped_spelling_as_the_same_address_as_its_ipv4() {
823 // `::ffff:a.b.c.d` and `a.b.c.d` are the same endpoint and the same IPv4 reachability (which
824 // is why `dig_ip::Family` ranks both V4), so they are one dial attempt, not two.
825 let rec = record_with(vec![
826 CandidateAddr::direct("::ffff:203.0.113.7", 9444),
827 CandidateAddr::direct("203.0.113.7", 9444),
828 ]);
829 assert_eq!(rec.dial_candidates().len(), 1);
830 }
831
832 #[test]
833 fn dial_candidates_keep_distinct_ports_of_one_host_apart() {
834 // Dedup is per ENDPOINT: the same host on two ports is two genuine dial targets.
835 let rec = record_with(vec![
836 CandidateAddr::direct("2001:db8::1", 9444),
837 CandidateAddr::direct("2001:db8::1", 9445),
838 ]);
839 assert_eq!(rec.dial_candidates().len(), 2);
840 }
841}