Skip to main content

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/// Maximum byte length of a [`CandidateAddr::host`], and the reason it is a naming limit rather
168/// than a round number.
169///
170/// 253 is the maximum PRESENTATION length of a fully-qualified DNS name (RFC 1035 §2.3.4 bounds the
171/// wire form at 255 octets, of which the root label's length byte and terminator are not written in
172/// text form), so it admits every legal hostname. It also contains every IPv6 literal spelling with
173/// room to spare — the longest, a fully-expanded IPv4-mapped address carrying a zone id, is well
174/// under 100 characters. Nothing legitimate reaches it.
175///
176/// Without this bound `host` is unbounded: the struct's fields are `pub`, so a record decoded off
177/// the wire — or built by literal — can carry a body-sized host. The victim stores it, folds it into
178/// its routing table ([`crate::service`]'s admission pipeline), and re-serves it in every
179/// `Providers` answer for that key, which no outbound cap trims. Every querier's framing check then
180/// rejects the answer ([`MAX_FRAMED_BODY`](crate::wire::MAX_FRAMED_BODY)) and the key is
181/// undiscoverable through that node for a full TTL — and because the poisoned contact is in the
182/// routing table, it bloats the `closer` list of OTHER targets too. Capping the address COUNT does
183/// not close this: one address is enough.
184pub const MAX_HOST_LEN: usize = 253;
185
186/// Sort `addresses` **IPv6-first-then-rank** (see [`sort_addresses_ipv6_first`]) and then truncate
187/// to [`MAX_ADDRESSES_PER_RECORD`], so the most-preferred candidates are the ones kept when a list
188/// exceeds the cap. This is the one admission point both the constructors ([`ProviderRecord::new`],
189/// [`crate::routing::Contact::new`]) and the wire-decode boundary (`handle_request_from`'s
190/// `AddProvider` arm, and contacts folded in from lookup responses) MUST call before accepting an
191/// address list from any source that did not already go through it — a `ProviderRecord` /
192/// `Contact` deserialized directly from the wire bypasses the constructors entirely (their fields
193/// are public), so capping only in `new` would not close the untrusted-input path.
194///
195/// It also **bounds each entry**, not just their number: a candidate whose `host` exceeds
196/// [`MAX_HOST_LEN`] is DROPPED before the sort. Dropping rather than truncating is deliberate — a
197/// truncated host names a DIFFERENT, possibly real, endpoint, so trimming one would fabricate an
198/// address rather than discard an unusable one. Dropping follows the same normalize-never-reject
199/// rule the rest of this module holds: the record survives, minus the address that could not be
200/// represented, which is exactly as useful as a record that never carried it.
201pub(crate) fn sort_and_cap_addresses(addresses: &mut Vec<CandidateAddr>) {
202    addresses.retain(|a| a.host.len() <= MAX_HOST_LEN);
203    sort_addresses_ipv6_first(addresses);
204    addresses.truncate(MAX_ADDRESSES_PER_RECORD);
205}
206
207/// Decode a canonical 64-hex string into 32 bytes, or `None` if it is not exactly 64 hex digits.
208///
209/// The ONE hex-decode in this crate (`peer_id`, content key and mirror-coin id all share this
210/// shape), so a second, subtly different decoder cannot drift into existence.
211pub(crate) fn hex64_to_bytes(hex: &str) -> Option<[u8; 32]> {
212    if hex.len() != 64 {
213        return None;
214    }
215    let mut out = [0u8; 32];
216    for (i, chunk) in hex.as_bytes().chunks(2).enumerate() {
217        let hi = (chunk[0] as char).to_digit(16)?;
218        let lo = (chunk[1] as char).to_digit(16)?;
219        out[i] = ((hi << 4) | lo) as u8;
220    }
221    Some(out)
222}
223
224/// Encode 32 bytes as canonical lowercase 64-hex — the inverse of [`hex64_to_bytes`].
225pub(crate) fn to_hex64(bytes: &[u8; 32]) -> String {
226    use std::fmt::Write as _;
227    let mut out = String::with_capacity(64);
228    for b in bytes {
229        let _ = write!(out, "{b:02x}");
230    }
231    out
232}
233
234/// Normalize an [`unverified_mirror_coin_id`](ProviderRecord::unverified_mirror_coin_id) to its
235/// canonical form: a lowercase 64-hex string, or `None` for anything else.
236///
237/// **Normalize, never reject.** This field is attacker-supplied and OPTIONAL, so a malformed value
238/// must cost the record nothing: erroring would let any peer destroy a whole provider record - and
239/// with it the discovery the DHT exists for - by appending one junk field. Dropping it instead
240/// leaves the record exactly as useful as one that never carried a pointer, which is the defined
241/// fallback.
242///
243/// It also BOUNDS the field, which is why it runs at EVERY ingress rather than only at the wire.
244/// The value is otherwise unbounded: the struct's fields are `pub`, so a record built by literal -
245/// how a consumer folds a verified holdings-announce in - can carry a 256 KiB pointer (the frame
246/// ceiling, [`MAX_FRAMED_BODY`](crate::wire::MAX_FRAMED_BODY)). Stored verbatim it is re-served in
247/// every `Providers` response for that key, which no outbound cap trims, so every querier's framing
248/// check rejects the answer and the key goes undiscoverable through this node for a full TTL. That
249/// is the same amplification the address cap closes, and the same discovery denial this
250/// normalize-never-reject rule exists to prevent.
251///
252/// Lowercasing is not cosmetic: without it the same coin published in two cases yields two
253/// non-equal records, so dedup and equality would split on presentation.
254pub(crate) fn normalize_mirror_coin_id(raw: Option<&str>) -> Option<String> {
255    raw.map(str::to_ascii_lowercase)
256        .filter(|s| hex64_to_bytes(s).is_some())
257}
258
259/// Wire-boundary normalization for
260/// [`unverified_mirror_coin_id`](ProviderRecord::unverified_mirror_coin_id): anything that is not a
261/// 64-hex string becomes `None`, and a valid one is lowercased.
262///
263/// **Normalize, never reject.** This field is attacker-supplied and OPTIONAL, so a malformed value
264/// must cost the record nothing: erroring here would let any peer destroy a whole provider record —
265/// and with it the discovery the DHT exists for — by appending one junk field. Dropping it instead
266/// leaves the record exactly as useful as one that never carried a pointer, which is the defined
267/// fallback. It also bounds the field: a peer can otherwise put a body-sized string here (the frame
268/// ceiling is [`MAX_FRAMED_BODY`](crate::wire::MAX_FRAMED_BODY), 256 KiB) which the victim would
269/// store AND re-serve to every querying peer — the same amplification the address cap closes.
270///
271/// Lowercasing is not cosmetic: without it the same coin published in two cases yields two
272/// non-equal records, so dedup and equality would split on presentation.
273fn deserialize_mirror_coin_id<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
274where
275    D: serde::Deserializer<'de>,
276{
277    let raw = Option::<serde_json::Value>::deserialize(deserializer)?;
278    Ok(normalize_mirror_coin_id(
279        raw.as_ref().and_then(|v| v.as_str()),
280    ))
281}
282
283/// Upper bound on how many candidates [`dial_candidates`] hands a dialer for ONE peer, so a record
284/// padded with addresses cannot turn a single holder into a connect storm. Byte-for-byte the same
285/// bound dig-download applies on its own dial path, so a consumer that adopts this iterator sees no
286/// change in attempt count.
287pub const MAX_DIAL_CANDIDATES: usize = 4;
288
289/// The dialable candidates of `addresses`, in **dial order**: IPv6 first, then IPv4, then anything
290/// unresolvable — deduped by `host:port` and capped at [`MAX_DIAL_CANDIDATES`].
291///
292/// This is the §5.2-compliant order (IPv6-first, IPv4-**fallback**) and the ONE place the DHT
293/// expresses it, so every consumer inherits it instead of re-deriving a ranking of its own. A dialer
294/// walks the WHOLE list and only reports failure once every candidate has been tried: in #836 a
295/// reader instead took a single address, tried one IPv6 literal, and gave up while a working IPv4
296/// candidate sat unused — v4 is the fallback, so a failed v6 attempt MUST fall through to it.
297///
298/// Relay markers are excluded (they are not directly dialable — reach those peers via the relay /
299/// a brokered punch). Unresolvable candidates are KEPT, last, on purpose: a dialer that walks them
300/// can report a concrete per-candidate reason instead of pretending the provider had no address.
301pub fn dial_candidates(addresses: &[CandidateAddr]) -> Vec<&CandidateAddr> {
302    let mut candidates: Vec<&CandidateAddr> =
303        addresses.iter().filter(|a| a.kind.is_dialable()).collect();
304    // Sorted defensively rather than trusting the stored order: the same ranking is applied when a
305    // list is constructed or deserialized, but `addresses` is a public field any caller may rewrite.
306    candidates.sort_by_key(|a| a.family_then_kind_rank());
307    let mut seen = std::collections::HashSet::new();
308    candidates.retain(|a| seen.insert(a.dial_identity()));
309    reserve_fallback_slot_and_cap(&mut candidates);
310    candidates
311}
312
313/// Truncate `candidates` to [`MAX_DIAL_CANDIDATES`] while KEEPING the fallback tier represented.
314///
315/// Truncating the family-sorted list outright would let the preferred tier fill the cap on its own: a
316/// holder advertising four or more IPv6 candidates would yield a dial set containing no IPv4 at all,
317/// so a dialer that faithfully walked every candidate it was given would STILL never reach the working
318/// address — precisely the #836 read-leg failure this iterator exists to prevent, and a violation of
319/// the rule that a failed IPv6 attempt must never mask a working IPv4 one. It needs no attacker: a
320/// dual-stack holder legitimately emits direct + mapped + reflexive IPv6 candidates, and an IPv6
321/// address with no working route is ordinary.
322///
323/// So when the cap would exclude EVERY non-IPv6 candidate and one exists, the least-preferred kept
324/// slot is given to the best non-IPv6 candidate. IPv6 still leads the list — the reservation costs one
325/// surplus IPv6 attempt, never the ordering.
326fn reserve_fallback_slot_and_cap(candidates: &mut Vec<&CandidateAddr>) {
327    if candidates.len() <= MAX_DIAL_CANDIDATES {
328        return;
329    }
330    let kept_excludes_every_fallback = candidates[..MAX_DIAL_CANDIDATES]
331        .iter()
332        .all(|a| a.is_ipv6_literal());
333    let fallback = kept_excludes_every_fallback
334        .then(|| candidates.iter().find(|a| !a.is_ipv6_literal()).copied())
335        .flatten();
336    match fallback {
337        Some(fallback) => {
338            candidates.truncate(MAX_DIAL_CANDIDATES - 1);
339            candidates.push(fallback);
340        }
341        None => candidates.truncate(MAX_DIAL_CANDIDATES),
342    }
343}
344
345/// serde hook applied to every `addresses` field ([`ProviderRecord`], [`crate::routing::Contact`]),
346/// so [`MAX_ADDRESSES_PER_RECORD`] holds **by construction** for any value that is deserialized —
347/// from a peer's wire frame, a config file, or a cached snapshot — and not only at the ingest call
348/// sites that remember to call [`sort_and_cap_addresses`] (§14). Deserialization is the ONE
349/// unavoidable gate every untrusted address list passes through; enforcing the bound there means a
350/// future ingest path cannot silently reintroduce an unbounded list.
351///
352/// It **bounds rather than rejects**: a list longer than the cap is sorted and truncated, never
353/// turned into a decode error. Rejecting would make a nonconforming (or simply older, looser)
354/// producer's record unparseable, which the store-format compatibility rule forbids — and would
355/// hand a peer an easy way to poison a whole frame. Sorting before truncating keeps the
356/// most-preferred candidates, so a hostile peer cannot bury the one reachable address behind filler.
357pub(crate) fn deserialize_capped_addresses<'de, D>(
358    deserializer: D,
359) -> Result<Vec<CandidateAddr>, D::Error>
360where
361    D: serde::Deserializer<'de>,
362{
363    let mut addresses = Vec::<CandidateAddr>::deserialize(deserializer)?;
364    sort_and_cap_addresses(&mut addresses);
365    Ok(addresses)
366}
367
368/// The DHT's stored value: peer `provider_peer_id` holds the content whose key is `content_key`,
369/// reachable at `addresses`, until `expires_at`.
370///
371/// - `content_key` is the 64-hex [`Key`](crate::Key) the content id hashed to — the DHT stores by
372///   key, not by the (larger, granularity-tagged) content id, so a record is compact and the store
373///   is a pure key→providers map.
374/// - `provider_peer_id` is the 64-hex `peer_id` of the holder; a finder builds a `PeerTarget` from
375///   it plus `addresses` and connects via dig-nat.
376/// - `expires_at` is absolute Unix seconds; a record past its expiry is treated as absent and GC'd.
377///   The holder republishes (a fresh record with a new `expires_at`) before expiry to stay findable.
378#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
379pub struct ProviderRecord {
380    /// The content key (64-hex) this record provides for — the [`Key`](crate::Key) a content id
381    /// hashed to.
382    pub content_key: String,
383    /// The holder's `peer_id` (64-hex).
384    pub provider_peer_id: String,
385    /// Candidate addresses to reach the holder, ordered IPv6-first then most-direct-first by
386    /// [`AddressKind::rank`] and bounded to [`MAX_ADDRESSES_PER_RECORD`] — held by BOTH
387    /// [`ProviderRecord::new`] and deserialization ([`deserialize_capped_addresses`]), so a record
388    /// off the wire carries the same guarantee as a constructed one.
389    #[serde(deserialize_with = "deserialize_capped_addresses")]
390    pub addresses: Vec<CandidateAddr>,
391    /// Absolute expiry (Unix seconds). A record at/after this time is stale.
392    pub expires_at: u64,
393    /// **UNTRUSTED POINTER, NOT EVIDENCE** — an optional 64-hex mirror-coin id the publisher claims
394    /// bonds this `(store, root)` claim, carried so a verifier can fetch ONE coin instead of
395    /// searching for it.
396    ///
397    /// Holding this proves nothing whatsoever. Any peer can publish any 32 bytes, and a hostile or
398    /// merely stale publisher can supply a real, well-formed, fully-collateralised coin id that
399    /// bonds a **different** store, a different root, a different epoch, or a different owner —
400    /// every property checks out except the one that matters. A consumer MUST, against its own
401    /// chain source:
402    ///
403    /// 1. fetch the coin and verify it sits at `dig_mirror_coin::mirror_coin_puzzle_hash()`,
404    /// 2. verify it is $DIG with the asset id re-derived from the creating spend,
405    /// 3. verify it carries the full collateral, and
406    /// 4. confirm the coin's DECLARED bond matches the claim — `advertises(store, root, epoch)` is
407    ///    an exact equality on the declared triple, and the owner is checked against the four-term
408    ///    `dig_mirror_coin::mirror_hint(store, root, owner_puzzle_hash, epoch)`.
409    ///
410    /// Step 4 is what binds the coin to the claim; 1-3 alone prove only that *a* valid mirror coin
411    /// exists somewhere. No verification happens in this crate — the DHT has no chain source.
412    ///
413    /// **Absence is normal and must never degrade discovery.** Old publishers, publishers that have
414    /// not created the coin yet, and publishers mid-epoch-rollover all legitimately omit it; a
415    /// republished record can also carry a pointer that has since gone stale across an epoch
416    /// boundary. **A store-granularity record can never carry one at all**, since a mirror coin
417    /// bonds `(store, root, owner, epoch)` and a store-granularity claim names no root. Treating a
418    /// missing pointer as "uncollateralised" is a defect.
419    ///
420    /// **There is no equally cheap fallback, and none is required.** `dig_mirror_coin::discover`
421    /// cannot serve as one: it takes `owner_puzzle_hash` as a required parameter, because the hint
422    /// is morphed from the owner, and a provider record carries no owner. An owner-less scan of the
423    /// shared mirror puzzle hash is not merely slower — it is truncated at `MAX_CANDIDATES` on a
424    /// list anyone may extend for the price of a dust coin, so its "not found" is not a negative a
425    /// verifier may act on, and it turns one free inbound record into an unbounded outbound read. A
426    /// consumer that cannot fetch a pointer therefore **withholds credit and leaves the holder's
427    /// ranking unchanged**; it does not demote.
428    ///
429    /// **A wrong pointer costs the publisher, not the verifier.** One chain read, no retry loop: a
430    /// lookup that misses or fails the bond check yields no credit for that holder. A mismatch is
431    /// not grounds for blocklisting — it is indistinguishable from an epoch rollover.
432    ///
433    /// Malformed values normalize to `None` at the wire boundary and again at admission, so on an
434    /// ADMITTED or STORED record this is either a canonical lowercase 64-hex string or absent. That
435    /// guarantee belongs to those records only: the field is `pub`, so a value that has not yet
436    /// passed either normalization can hold arbitrary attacker-shaped bytes of arbitrary length. A
437    /// consumer holding a record from any other source must normalize it itself.
438    #[serde(
439        default,
440        skip_serializing_if = "Option::is_none",
441        deserialize_with = "deserialize_mirror_coin_id"
442    )]
443    pub unverified_mirror_coin_id: Option<String>,
444}
445
446impl ProviderRecord {
447    /// Build a record: peer `provider` holds `content_key`, reachable at `addresses`, until
448    /// `expires_at` (absolute Unix seconds).
449    pub fn new(
450        content_key: &crate::key::Key,
451        provider: &PeerId,
452        mut addresses: Vec<CandidateAddr>,
453        expires_at: u64,
454    ) -> Self {
455        sort_and_cap_addresses(&mut addresses);
456        ProviderRecord {
457            content_key: content_key.to_hex(),
458            provider_peer_id: provider.to_hex(),
459            addresses,
460            expires_at,
461            unverified_mirror_coin_id: None,
462        }
463    }
464
465    /// Attach the publisher's claimed mirror-coin id — see
466    /// [`unverified_mirror_coin_id`](ProviderRecord::unverified_mirror_coin_id) for why holding it
467    /// proves nothing. Stored canonically (lowercase 64-hex) so two records naming the same coin are
468    /// byte-identical.
469    ///
470    /// Kept off [`new`](ProviderRecord::new) deliberately: the pointer is per-CONTENT rather than
471    /// per-node, because a mirror coin bonds a `(store, root, owner, epoch)` tuple, so only the
472    /// caller that knows which content it is announcing can supply it.
473    pub fn with_unverified_mirror_coin_id(mut self, coin_id: [u8; 32]) -> Self {
474        self.unverified_mirror_coin_id = Some(to_hex64(&coin_id));
475        self
476    }
477
478    /// The claimed mirror-coin id as 32 bytes, or `None` when absent (the normal fallback case).
479    ///
480    /// **The bytes are a lookup key, never a fact.** Returning `Some` means a publisher said
481    /// something, not that a collateral coin exists.
482    pub fn unverified_mirror_coin_id_bytes(&self) -> Option<[u8; 32]> {
483        self.unverified_mirror_coin_id
484            .as_deref()
485            .and_then(hex64_to_bytes)
486    }
487
488    /// The provider's `peer_id` decoded from the 64-hex field, or `None` if malformed.
489    pub fn provider_peer_id(&self) -> Option<PeerId> {
490        PeerId::from_hex(&self.provider_peer_id)
491    }
492
493    /// Whether this record is expired at `now` (Unix seconds) — stale records are dropped on read.
494    pub fn is_expired(&self, now: u64) -> bool {
495        now >= self.expires_at
496    }
497
498    /// The FIRST candidate only — the IPv6-preferred, most-direct dialable address, if any.
499    ///
500    /// **Prefer [`dial_candidates`](Self::dial_candidates) for dialing.** This returns one address,
501    /// so a caller that dials it and stops has made a single attempt and cannot fall back: an
502    /// unusable IPv6 candidate then masks a working IPv4 one, violating the IPv4-**fallback** half
503    /// of §5.2 (exactly the #836 read-leg failure). Use this only where a single representative
504    /// address is genuinely what is wanted — a log line, a display string, a metric label.
505    pub fn best_address(&self) -> Option<&CandidateAddr> {
506        self.addresses.iter().find(|a| a.kind.is_dialable())
507    }
508
509    /// This provider's dialable candidates in §5.2 dial order — see [`dial_candidates`] for the
510    /// ordering contract. Dial these in order, falling through on failure, before concluding the
511    /// holder is unreachable.
512    pub fn dial_candidates(&self) -> Vec<&CandidateAddr> {
513        dial_candidates(&self.addresses)
514    }
515}
516
517#[cfg(test)]
518mod tests {
519    use super::*;
520    use crate::key::Key;
521
522    fn pid(b: u8) -> PeerId {
523        PeerId::from_bytes([b; 32])
524    }
525
526    /// The 32 bytes a well-formed pointer decodes to, and its canonical lowercase spelling.
527    const COIN_ID: [u8; 32] = [
528        0x9a, 0x0b, 0xff, 0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef, 0x10, 0x20, 0x30, 0x40,
529        0x50, 0x60, 0x70, 0x80, 0x90, 0xa0, 0xb0, 0xc0, 0xd0, 0xe0, 0xf0, 0x11, 0x22, 0x33, 0x44,
530        0x55, 0x66,
531    ];
532    const COIN_ID_HEX: &str = "9a0bff0123456789abcdef102030405060708090a0b0c0d0e0f0112233445566";
533
534    fn plain_record() -> ProviderRecord {
535        ProviderRecord::new(
536            &Key::from_bytes([0xAB; 32]),
537            &pid(0x07),
538            vec![CandidateAddr::direct("203.0.113.7", 9444)],
539            1_000,
540        )
541    }
542
543    /// The pre-pointer record shape, byte-for-byte. A record produced by THIS crate must still
544    /// deserialize into it — that is what "an old peer parses a new record" means, and it is the
545    /// half a same-crate round-trip test cannot see.
546    #[derive(serde::Deserialize)]
547    struct LegacyProviderRecord {
548        content_key: String,
549        provider_peer_id: String,
550        addresses: Vec<CandidateAddr>,
551        expires_at: u64,
552    }
553
554    #[test]
555    fn a_pointer_round_trips_and_decodes_to_its_bytes() {
556        let rec = plain_record().with_unverified_mirror_coin_id(COIN_ID);
557        assert_eq!(rec.unverified_mirror_coin_id.as_deref(), Some(COIN_ID_HEX));
558
559        let json = serde_json::to_string(&rec).unwrap();
560        let back: ProviderRecord = serde_json::from_str(&json).unwrap();
561        assert_eq!(back, rec);
562        assert_eq!(back.unverified_mirror_coin_id_bytes(), Some(COIN_ID));
563    }
564
565    /// An OLD peer must parse a NEW record. Deserializing into the legacy shape proves the addition
566    /// is tolerated as an unknown field rather than merely being self-consistent.
567    #[test]
568    fn an_old_peer_parses_a_record_carrying_the_new_pointer() {
569        let rec = plain_record().with_unverified_mirror_coin_id(COIN_ID);
570        let json = serde_json::to_string(&rec).unwrap();
571
572        let legacy: LegacyProviderRecord = serde_json::from_str(&json).unwrap();
573        assert_eq!(legacy.content_key, rec.content_key);
574        assert_eq!(legacy.provider_peer_id, rec.provider_peer_id);
575        assert_eq!(legacy.addresses, rec.addresses);
576        assert_eq!(legacy.expires_at, rec.expires_at);
577    }
578
579    /// A NEW peer must parse an OLD record — absence is the normal case, never an error.
580    #[test]
581    fn a_new_peer_parses_a_record_with_no_pointer_field_at_all() {
582        let legacy_json = r#"{
583            "content_key": "abababababababababababababababababababababababababababababababab",
584            "provider_peer_id": "0707070707070707070707070707070707070707070707070707070707070707",
585            "addresses": [{"host":"203.0.113.7","port":9444,"kind":"direct"}],
586            "expires_at": 1000
587        }"#;
588        let rec: ProviderRecord = serde_json::from_str(legacy_json).unwrap();
589        assert_eq!(rec.unverified_mirror_coin_id, None);
590        assert_eq!(rec.unverified_mirror_coin_id_bytes(), None);
591        assert_eq!(rec, plain_record());
592    }
593
594    /// An absent pointer must be OMITTED from the wire, not emitted as `null`, so a record from a
595    /// publisher with no coin is byte-identical to one from a pre-pointer publisher.
596    #[test]
597    fn an_absent_pointer_is_omitted_from_the_wire_entirely() {
598        let json = serde_json::to_string(&plain_record()).unwrap();
599        assert!(
600            !json.contains("unverified_mirror_coin_id"),
601            "absent pointer leaked onto the wire: {json}"
602        );
603        assert!(
604            !json.contains("null"),
605            "absent pointer emitted as null: {json}"
606        );
607    }
608
609    /// Every malformed shape a hostile peer can put in the field normalizes to `None` — and NONE of
610    /// them may fail the parse. Erroring would let one junk field destroy a whole provider record,
611    /// which turns an optional convenience into a discovery-denial primitive.
612    ///
613    /// The oversize case is sized FROM the protocol limit: `wire::MAX_FRAMED_BODY` is 256 KiB, so a
614    /// peer really can put ~256 KiB here inside one legal frame.
615    #[test]
616    fn every_malformed_pointer_normalizes_to_none_without_failing_the_record() {
617        let oversize = "a".repeat(crate::wire::MAX_FRAMED_BODY - 512);
618        let cases: Vec<(&str, String)> = vec![
619            ("json null", "null".to_string()),
620            ("empty string", "\"\"".to_string()),
621            ("63 hex (one under)", format!("\"{}\"", "a".repeat(63))),
622            ("65 hex (one over)", format!("\"{}\"", "a".repeat(65))),
623            ("64 chars, not hex", format!("\"{}\"", "z".repeat(64))),
624            ("a number", "12345".to_string()),
625            ("a bool", "true".to_string()),
626            ("an object", "{\"coin\":1}".to_string()),
627            ("an array", "[1,2,3]".to_string()),
628            ("body-sized string", format!("\"{oversize}\"")),
629        ];
630
631        for (label, value) in cases {
632            let json = format!(
633                r#"{{
634                    "content_key": "abababababababababababababababababababababababababababababababab",
635                    "provider_peer_id": "0707070707070707070707070707070707070707070707070707070707070707",
636                    "addresses": [{{"host":"203.0.113.7","port":9444,"kind":"direct"}}],
637                    "expires_at": 1000,
638                    "unverified_mirror_coin_id": {value}
639                }}"#
640            );
641            let rec: ProviderRecord = serde_json::from_str(&json)
642                .unwrap_or_else(|e| panic!("{label} must not fail the record parse: {e}"));
643            assert_eq!(
644                rec.unverified_mirror_coin_id, None,
645                "{label} should have normalized to None"
646            );
647            // The rest of the record survives intact — a junk pointer degrades to the no-pointer
648            // case, which is exactly as useful as before.
649            assert_eq!(
650                rec,
651                plain_record(),
652                "{label} damaged the rest of the record"
653            );
654        }
655    }
656
657    /// The 64-hex bound pinned from BOTH sides: at-bound passes, one over fails. Tested through the
658    /// wire boundary so it pins the field, not only the helper.
659    #[test]
660    fn the_sixty_four_hex_bound_holds_from_both_sides() {
661        assert!(
662            hex64_to_bytes(&"a".repeat(64)).is_some(),
663            "at-bound must decode"
664        );
665        assert!(
666            hex64_to_bytes(&"a".repeat(65)).is_none(),
667            "one over must not decode"
668        );
669        assert!(
670            hex64_to_bytes(&"a".repeat(63)).is_none(),
671            "one under must not decode"
672        );
673    }
674
675    /// Uppercase hex is a valid id in a different presentation. It must decode to the SAME bytes and
676    /// be stored canonically, or two records naming one coin compare unequal and dedup splits.
677    #[test]
678    fn an_uppercase_pointer_is_canonicalized_rather_than_dropped() {
679        let json = format!(
680            r#"{{
681                "content_key": "abababababababababababababababababababababababababababababababab",
682                "provider_peer_id": "0707070707070707070707070707070707070707070707070707070707070707",
683                "addresses": [{{"host":"203.0.113.7","port":9444,"kind":"direct"}}],
684                "expires_at": 1000,
685                "unverified_mirror_coin_id": "{}"
686            }}"#,
687            COIN_ID_HEX.to_ascii_uppercase()
688        );
689        let rec: ProviderRecord = serde_json::from_str(&json).unwrap();
690        assert_eq!(rec.unverified_mirror_coin_id.as_deref(), Some(COIN_ID_HEX));
691        assert_eq!(rec.unverified_mirror_coin_id_bytes(), Some(COIN_ID));
692        assert_eq!(
693            rec,
694            plain_record().with_unverified_mirror_coin_id(COIN_ID),
695            "the same coin in two cases must produce equal records"
696        );
697    }
698
699    #[test]
700    fn record_round_trips_through_json() {
701        let key = Key::from_bytes([0xAB; 32]);
702        let rec = ProviderRecord::new(
703            &key,
704            &pid(0x07),
705            vec![CandidateAddr::direct("203.0.113.7", 9444)],
706            1_000,
707        );
708        let json = serde_json::to_string(&rec).unwrap();
709        let back: ProviderRecord = serde_json::from_str(&json).unwrap();
710        assert_eq!(rec, back);
711        assert_eq!(back.provider_peer_id().unwrap(), pid(0x07));
712        assert_eq!(back.content_key, key.to_hex());
713    }
714
715    #[test]
716    fn ttl_expiry() {
717        let rec = ProviderRecord::new(&Key::from_bytes([0u8; 32]), &pid(1), vec![], 100);
718        assert!(!rec.is_expired(99));
719        assert!(rec.is_expired(100));
720        assert!(rec.is_expired(101));
721    }
722
723    #[test]
724    fn address_kind_wire_tokens_are_lowercase() {
725        assert_eq!(
726            serde_json::to_string(&AddressKind::Direct).unwrap(),
727            "\"direct\""
728        );
729        assert_eq!(
730            serde_json::to_string(&AddressKind::Reflexive).unwrap(),
731            "\"reflexive\""
732        );
733        assert_eq!(
734            serde_json::to_string(&AddressKind::Mapped).unwrap(),
735            "\"mapped\""
736        );
737        assert_eq!(
738            serde_json::to_string(&AddressKind::Relay).unwrap(),
739            "\"relay\""
740        );
741    }
742
743    #[test]
744    fn best_address_prefers_most_direct() {
745        let key = Key::from_bytes([0u8; 32]);
746        let rec = ProviderRecord::new(
747            &key,
748            &pid(1),
749            vec![
750                CandidateAddr {
751                    host: "r".into(),
752                    port: 1,
753                    kind: AddressKind::Reflexive,
754                },
755                CandidateAddr::direct("d", 2),
756                CandidateAddr::relay_marker(),
757            ],
758            10,
759        );
760        assert_eq!(rec.best_address().unwrap().kind, AddressKind::Direct);
761    }
762
763    #[test]
764    fn best_address_none_when_only_relay() {
765        let key = Key::from_bytes([0u8; 32]);
766        let rec = ProviderRecord::new(&key, &pid(1), vec![CandidateAddr::relay_marker()], 10);
767        assert!(rec.best_address().is_none());
768    }
769
770    #[test]
771    fn address_rank_ordering() {
772        assert!(AddressKind::Direct.rank() < AddressKind::Mapped.rank());
773        assert!(AddressKind::Mapped.rank() < AddressKind::Reflexive.rank());
774        assert!(AddressKind::Reflexive.rank() < AddressKind::Relay.rank());
775        assert!(!AddressKind::Relay.is_dialable());
776        assert!(AddressKind::Direct.is_dialable());
777    }
778
779    #[test]
780    fn provider_record_new_sorts_addresses_ipv6_first() {
781        // Fed in IPv4-first order; the stored list must come out IPv6-first, then by rank.
782        let key = Key::from_bytes([0u8; 32]);
783        let rec = ProviderRecord::new(
784            &key,
785            &pid(1),
786            vec![
787                CandidateAddr::direct("203.0.113.7", 9444), // IPv4 direct
788                CandidateAddr::direct("2001:db8::1", 9444), // IPv6 direct
789                CandidateAddr {
790                    host: "198.51.100.2".into(),
791                    port: 1,
792                    kind: AddressKind::Reflexive,
793                }, // IPv4 reflexive
794                CandidateAddr {
795                    host: "2001:db8::2".into(),
796                    port: 1,
797                    kind: AddressKind::Reflexive,
798                }, // IPv6 reflexive
799            ],
800            10,
801        );
802        let hosts: Vec<&str> = rec.addresses.iter().map(|a| a.host.as_str()).collect();
803        assert_eq!(
804            hosts,
805            vec!["2001:db8::1", "2001:db8::2", "203.0.113.7", "198.51.100.2"],
806            "addresses must be IPv6-first, then ranked by AddressKind"
807        );
808    }
809
810    #[test]
811    fn family_key_derives_from_dig_ip_family() {
812        // The FAMILY half of the sort key comes from `dig_ip::Family`, the single ecosystem source
813        // of truth — not a hand-rolled `is_ipv6` heuristic. The load-bearing proof is the
814        // IPv4-mapped IPv6 case: `dig_ip::Family::of` classifies `::ffff:a.b.c.d` as V4 (it is IPv4
815        // reachability), so it must sort with IPv4, AFTER a genuine IPv6 address of the same kind. A
816        // `host.parse::<IpAddr>()`-based family key would have (wrongly) treated it as IPv6.
817        let key = Key::from_bytes([0u8; 32]);
818        let rec = ProviderRecord::new(
819            &key,
820            &pid(1),
821            vec![
822                CandidateAddr::direct("::ffff:203.0.113.9", 9444), // IPv4-mapped → V4 per dig-ip
823                CandidateAddr::direct("2001:db8::1", 9444),        // genuine IPv6 → V6
824            ],
825            10,
826        );
827        let hosts: Vec<&str> = rec.addresses.iter().map(|a| a.host.as_str()).collect();
828        assert_eq!(
829            hosts,
830            vec!["2001:db8::1", "::ffff:203.0.113.9"],
831            "an IPv4-mapped IPv6 address must sort as V4 (dig_ip::Family), after a genuine IPv6"
832        );
833    }
834
835    #[test]
836    fn directness_kind_rank_preserved_as_tiebreak_within_a_family() {
837        // Within ONE address family the dht-specific most-direct-first `AddressKind::rank` tiebreak
838        // MUST survive the migration to dig-ip family keying: same family, different directness →
839        // Direct before Mapped before Reflexive.
840        let key = Key::from_bytes([0u8; 32]);
841        let rec = ProviderRecord::new(
842            &key,
843            &pid(1),
844            vec![
845                CandidateAddr {
846                    host: "2001:db8::3".into(),
847                    port: 1,
848                    kind: AddressKind::Reflexive,
849                },
850                CandidateAddr {
851                    host: "2001:db8::2".into(),
852                    port: 1,
853                    kind: AddressKind::Mapped,
854                },
855                CandidateAddr::direct("2001:db8::1", 9444),
856            ],
857            10,
858        );
859        let hosts: Vec<&str> = rec.addresses.iter().map(|a| a.host.as_str()).collect();
860        assert_eq!(
861            hosts,
862            vec!["2001:db8::1", "2001:db8::2", "2001:db8::3"],
863            "within one family, addresses must stay ordered by AddressKind::rank (most-direct first)"
864        );
865    }
866
867    #[test]
868    fn best_address_prefers_ipv6_over_ipv4_at_same_rank() {
869        let key = Key::from_bytes([0u8; 32]);
870        let rec = ProviderRecord::new(
871            &key,
872            &pid(1),
873            vec![
874                CandidateAddr::direct("203.0.113.7", 9444), // IPv4 direct, fed first
875                CandidateAddr::direct("2001:db8::1", 9444), // IPv6 direct, fed second
876            ],
877            10,
878        );
879        assert_eq!(rec.best_address().unwrap().host, "2001:db8::1");
880    }
881
882    // ---- Address-list cap (MEDIUM: no cap on addresses[], SECURITY_AUDIT_P2P.md #179) ----
883
884    #[test]
885    fn provider_record_new_caps_addresses_at_the_constant() {
886        // Feed far more than the cap — a hostile/misconfigured caller must never make a
887        // constructed record carry an unbounded address list.
888        let key = Key::from_bytes([0u8; 32]);
889        let many: Vec<CandidateAddr> = (0..1000)
890            .map(|i| CandidateAddr::direct(format!("203.0.113.{}", i % 255), 9444))
891            .collect();
892        let rec = ProviderRecord::new(&key, &pid(1), many, 10);
893        assert_eq!(rec.addresses.len(), MAX_ADDRESSES_PER_RECORD);
894    }
895
896    #[test]
897    fn provider_record_new_cap_keeps_most_preferred_after_sort() {
898        // The cap must apply AFTER the IPv6-first-then-rank sort, so truncation drops the LEAST
899        // preferred candidates, not an arbitrary prefix of the input order.
900        let key = Key::from_bytes([0u8; 32]);
901        let mut addrs: Vec<CandidateAddr> = Vec::new();
902        // One preferred IPv6 direct address that must survive the cap...
903        addrs.push(CandidateAddr::direct("2001:db8::1", 9444));
904        // ...buried behind far more than the cap worth of low-preference IPv4 relay markers.
905        for i in 0..1000u32 {
906            addrs.push(CandidateAddr {
907                host: format!("198.51.100.{}", i % 255),
908                port: 1,
909                kind: AddressKind::Relay,
910            });
911        }
912        let rec = ProviderRecord::new(&key, &pid(1), addrs, 10);
913        assert_eq!(rec.addresses.len(), MAX_ADDRESSES_PER_RECORD);
914        assert_eq!(
915            rec.addresses[0].host, "2001:db8::1",
916            "the single most-preferred (IPv6 direct) candidate must survive truncation"
917        );
918    }
919
920    // ---- Deserialization-time address bound (#1514) ----
921
922    /// Build the JSON of a record carrying `n` addresses — the shape a hostile peer frames on the
923    /// wire, bypassing `ProviderRecord::new` entirely (its fields are public).
924    fn record_json_with_addresses(n: usize) -> String {
925        let addrs: Vec<String> = (0..n)
926            .map(|i| {
927                format!(
928                    r#"{{"host":"198.51.100.{}","port":1,"kind":"relay"}}"#,
929                    i % 255
930                )
931            })
932            .collect();
933        format!(
934            r#"{{"content_key":"{}","provider_peer_id":"{}","addresses":[{}],"expires_at":1}}"#,
935            "aa".repeat(32),
936            "bb".repeat(32),
937            addrs.join(",")
938        )
939    }
940
941    #[test]
942    fn deserialization_bounds_the_address_count() {
943        // #1514: the cap must hold BY CONSTRUCTION at the decode boundary, not only at the ingest
944        // call sites that remember to call `sort_and_cap_addresses`. Stated over the CLASS: no
945        // deserialized record, from any source, ever carries more than the cap.
946        let rec: ProviderRecord = serde_json::from_str(&record_json_with_addresses(1000)).unwrap();
947        assert_eq!(rec.addresses.len(), MAX_ADDRESSES_PER_RECORD);
948    }
949
950    #[test]
951    fn deserialization_bound_is_one_off_exact() {
952        // The one-off variant: exactly the cap survives untouched; exactly one more is bounded.
953        let at_cap: ProviderRecord =
954            serde_json::from_str(&record_json_with_addresses(MAX_ADDRESSES_PER_RECORD)).unwrap();
955        assert_eq!(at_cap.addresses.len(), MAX_ADDRESSES_PER_RECORD);
956        let over_by_one: ProviderRecord =
957            serde_json::from_str(&record_json_with_addresses(MAX_ADDRESSES_PER_RECORD + 1))
958                .unwrap();
959        assert_eq!(over_by_one.addresses.len(), MAX_ADDRESSES_PER_RECORD);
960    }
961
962    #[test]
963    fn deserialization_keeps_the_most_preferred_addresses() {
964        // Bounding must drop the LEAST preferred candidates, so a hostile peer cannot bury the one
965        // genuinely reachable address behind a wall of filler and have it truncated away.
966        let mut addrs: Vec<String> =
967            vec![r#"{"host":"2001:db8::1","port":9444,"kind":"direct"}"#.to_string()];
968        for i in 0..1000 {
969            addrs.push(format!(
970                r#"{{"host":"198.51.100.{}","port":1,"kind":"relay"}}"#,
971                i % 255
972            ));
973        }
974        // The preferred candidate sits LAST in the wire order, so a naive prefix-truncation would
975        // discard exactly the address that matters.
976        addrs.rotate_left(1);
977        let json = format!(
978            r#"{{"content_key":"{}","provider_peer_id":"{}","addresses":[{}],"expires_at":1}}"#,
979            "aa".repeat(32),
980            "bb".repeat(32),
981            addrs.join(",")
982        );
983        let rec: ProviderRecord = serde_json::from_str(&json).unwrap();
984        assert_eq!(rec.addresses.len(), MAX_ADDRESSES_PER_RECORD);
985        assert_eq!(
986            rec.addresses[0].host, "2001:db8::1",
987            "the most-preferred candidate must survive the bound regardless of wire position"
988        );
989    }
990
991    // ---- Ordered dial candidates (#1594) ----
992
993    fn record_with(addresses: Vec<CandidateAddr>) -> ProviderRecord {
994        ProviderRecord::new(&Key::from_bytes([0u8; 32]), &pid(1), addresses, 10)
995    }
996
997    #[test]
998    fn dial_candidates_order_v6_then_v4_then_unresolvable() {
999        let rec = record_with(vec![
1000            CandidateAddr::direct("not-a-literal", 9444),
1001            CandidateAddr::direct("203.0.113.7", 9444),
1002            CandidateAddr::direct("2001:db8::1", 9444),
1003        ]);
1004        let hosts: Vec<&str> = rec
1005            .dial_candidates()
1006            .iter()
1007            .map(|a| a.host.as_str())
1008            .collect();
1009        assert_eq!(
1010            hosts,
1011            vec!["2001:db8::1", "203.0.113.7", "not-a-literal"],
1012            "dial order is IPv6, then IPv4, then anything unresolvable (§5.2)"
1013        );
1014    }
1015
1016    #[test]
1017    fn dial_candidates_keep_the_ipv4_fallback_behind_an_ipv6_candidate() {
1018        // The #836 failure this exists to prevent: a probe took `best_address()` alone, tried ONE
1019        // IPv6 literal, and gave up while a working IPv4 candidate sat unused. IPv4 is the FALLBACK
1020        // (§5.2), so it MUST still be present, after the v6 candidate, for a dialer to walk to.
1021        let rec = record_with(vec![
1022            CandidateAddr::direct("2001:db8::1", 9444),
1023            CandidateAddr::direct("172.31.79.22", 9444),
1024        ]);
1025        let candidates = rec.dial_candidates();
1026        assert_eq!(candidates.len(), 2, "the fallback must not be dropped");
1027        assert_eq!(candidates[0].host, "2001:db8::1");
1028        assert_eq!(candidates[1].host, "172.31.79.22");
1029    }
1030
1031    #[test]
1032    fn dial_candidates_treat_v4_mapped_v6_as_ipv4() {
1033        // Canonical IPv4-in-IPv6 rule: `::ffff:a.b.c.d` is IPv4 REACHABILITY, so it must order with
1034        // IPv4 — after a genuine IPv6 candidate. This is the one case where a hand-rolled
1035        // `is_ipv6`-style check silently disagrees with `dig_ip::Family`.
1036        let rec = record_with(vec![
1037            CandidateAddr::direct("::ffff:203.0.113.9", 9444),
1038            CandidateAddr::direct("2001:db8::1", 9444),
1039        ]);
1040        let hosts: Vec<&str> = rec
1041            .dial_candidates()
1042            .iter()
1043            .map(|a| a.host.as_str())
1044            .collect();
1045        assert_eq!(hosts, vec!["2001:db8::1", "::ffff:203.0.113.9"]);
1046    }
1047
1048    #[test]
1049    fn dial_candidates_exclude_relay_markers() {
1050        let rec = record_with(vec![
1051            CandidateAddr::relay_marker(),
1052            CandidateAddr::direct("2001:db8::1", 9444),
1053        ]);
1054        let candidates = rec.dial_candidates();
1055        assert_eq!(
1056            candidates.len(),
1057            1,
1058            "a relay marker is not directly dialable"
1059        );
1060        assert_eq!(candidates[0].host, "2001:db8::1");
1061    }
1062
1063    #[test]
1064    fn dial_candidates_are_bounded_and_deduped() {
1065        // A record may legitimately carry up to MAX_ADDRESSES_PER_RECORD candidates; a dialer must
1066        // not turn one provider into an unbounded connect storm, and must not waste an attempt
1067        // re-dialing the same host:port twice.
1068        let mut addresses = vec![CandidateAddr::direct("2001:db8::1", 9444); 3];
1069        addresses.extend((0..5).map(|i| CandidateAddr::direct(format!("10.0.0.{i}"), 9444)));
1070        let rec = record_with(addresses);
1071        let candidates = rec.dial_candidates();
1072        assert_eq!(candidates.len(), MAX_DIAL_CANDIDATES);
1073        assert_eq!(
1074            candidates
1075                .iter()
1076                .filter(|a| a.host == "2001:db8::1")
1077                .count(),
1078            1,
1079            "a repeated host:port contributes exactly one dial attempt"
1080        );
1081    }
1082
1083    #[test]
1084    fn dial_candidates_of_a_relay_only_record_are_empty() {
1085        let rec = record_with(vec![CandidateAddr::relay_marker()]);
1086        assert!(rec.dial_candidates().is_empty());
1087    }
1088
1089    #[test]
1090    fn unresolvable_host_sorts_after_an_ipv4_literal_in_the_stored_order() {
1091        // The stored order and the dial order share ONE ranking policy, so a hostname (which is not
1092        // reachability the DHT can classify) must never outrank a usable IPv4 literal anywhere.
1093        let rec = record_with(vec![
1094            CandidateAddr::direct("not-a-literal", 1),
1095            CandidateAddr::direct("203.0.113.7", 1),
1096        ]);
1097        let hosts: Vec<&str> = rec.addresses.iter().map(|a| a.host.as_str()).collect();
1098        assert_eq!(hosts, vec!["203.0.113.7", "not-a-literal"]);
1099    }
1100
1101    #[test]
1102    fn dial_candidates_reserve_a_slot_for_the_ipv4_fallback() {
1103        // #836 again, one layer down: truncating to MAX_DIAL_CANDIDATES *after* the family sort means
1104        // a record carrying four or more IPv6 candidates yields a dial set with ZERO IPv4 — so a
1105        // dialer walking every candidate it is given still never reaches the working address. That
1106        // contradicts the SPEC 5.5 MUST that a failed IPv6 attempt never masks a working IPv4 one.
1107        // A dual-stack holder legitimately emits direct + mapped + reflexive v6, so this is reachable
1108        // without an attacker; an IPv6 address with no working route is the common AWS case.
1109        let rec = record_with(vec![
1110            CandidateAddr::direct("2001:db8::1", 9444),
1111            CandidateAddr::direct("2001:db8::2", 9444),
1112            CandidateAddr::direct("2001:db8::3", 9444),
1113            CandidateAddr::direct("2001:db8::4", 9444),
1114            CandidateAddr::direct("203.0.113.7", 9444),
1115        ]);
1116        let candidates = rec.dial_candidates();
1117        assert_eq!(candidates.len(), MAX_DIAL_CANDIDATES);
1118        assert!(
1119            candidates.iter().any(|a| a.host == "203.0.113.7"),
1120            "the IPv4 fallback tier must keep a slot inside the cap, got {:?}",
1121            candidates.iter().map(|a| &a.host).collect::<Vec<_>>()
1122        );
1123        assert_eq!(
1124            candidates[0].host, "2001:db8::1",
1125            "IPv6 still leads — the reservation costs the LEAST preferred v6 slot, not the order"
1126        );
1127    }
1128
1129    #[test]
1130    fn dial_candidates_reserve_the_fallback_only_when_it_would_be_lost() {
1131        // The one-off variant either side of the cap: at exactly the cap nothing is dropped and no
1132        // reservation is needed, so a v4 that already fits must not be promoted out of order.
1133        let rec = record_with(vec![
1134            CandidateAddr::direct("2001:db8::1", 9444),
1135            CandidateAddr::direct("2001:db8::2", 9444),
1136            CandidateAddr::direct("2001:db8::3", 9444),
1137            CandidateAddr::direct("203.0.113.7", 9444),
1138        ]);
1139        let hosts: Vec<&str> = rec
1140            .dial_candidates()
1141            .iter()
1142            .map(|a| a.host.as_str())
1143            .collect();
1144        assert_eq!(
1145            hosts,
1146            vec!["2001:db8::1", "2001:db8::2", "2001:db8::3", "203.0.113.7"]
1147        );
1148    }
1149
1150    #[test]
1151    fn dial_candidates_dedupe_equivalent_spellings_of_one_address() {
1152        // Dedup on the RAW host string lets one address spelled four ways consume every slot, which
1153        // is the fallback-starvation above with no distinct addresses at all. Equivalence is a
1154        // property of the parsed IpAddr, not of the text.
1155        let rec = record_with(vec![
1156            CandidateAddr::direct("2001:db8::1", 9444),
1157            CandidateAddr::direct("2001:0db8::1", 9444),
1158            CandidateAddr::direct("2001:db8:0:0:0:0:0:1", 9444),
1159            CandidateAddr::direct("2001:DB8::1", 9444),
1160            CandidateAddr::direct("203.0.113.7", 9444),
1161        ]);
1162        let candidates = rec.dial_candidates();
1163        assert_eq!(
1164            candidates.len(),
1165            2,
1166            "four spellings of one IPv6 address are ONE dial attempt, got {:?}",
1167            candidates.iter().map(|a| &a.host).collect::<Vec<_>>()
1168        );
1169        assert!(candidates.iter().any(|a| a.host == "203.0.113.7"));
1170    }
1171
1172    #[test]
1173    fn dial_candidates_treat_a_v4_mapped_spelling_as_the_same_address_as_its_ipv4() {
1174        // `::ffff:a.b.c.d` and `a.b.c.d` are the same endpoint and the same IPv4 reachability (which
1175        // is why `dig_ip::Family` ranks both V4), so they are one dial attempt, not two.
1176        let rec = record_with(vec![
1177            CandidateAddr::direct("::ffff:203.0.113.7", 9444),
1178            CandidateAddr::direct("203.0.113.7", 9444),
1179        ]);
1180        assert_eq!(rec.dial_candidates().len(), 1);
1181    }
1182
1183    #[test]
1184    fn dial_candidates_keep_distinct_ports_of_one_host_apart() {
1185        // Dedup is per ENDPOINT: the same host on two ports is two genuine dial targets.
1186        let rec = record_with(vec![
1187            CandidateAddr::direct("2001:db8::1", 9444),
1188            CandidateAddr::direct("2001:db8::1", 9445),
1189        ]);
1190        assert_eq!(rec.dial_candidates().len(), 2);
1191    }
1192}