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