dig_download/addr.rs
1//! Provider-candidate address resolution — the ONE place a DHT [`CandidateAddr`] becomes a dialable
2//! [`SocketAddr`], and the ONE place a candidate is rendered as text.
3//!
4//! # Why this module exists
5//!
6//! A candidate's `host` is an IP **literal** (v4, v6, or v4-mapped-v6). Composing `"{host}:{port}"`
7//! and parsing that back as a [`SocketAddr`] is WRONG for every IPv6 literal — the socket-address
8//! grammar requires brackets (`[2001:db8::1]:9444`), so an unbracketed v6 host fails with
9//! `invalid socket address syntax` before a socket is ever opened. That format-then-reparse round
10//! trip killed the whole #836 read leg on an AWS host advertising `::ffff:172.31.79.22`.
11//!
12//! So: **parse the host as an [`IpAddr`] and CONSTRUCT the [`SocketAddr`]** — no string grammar in
13//! the middle. Rendering is the inverse and goes through [`display`], which brackets v6 correctly.
14//!
15//! # Candidate order (§5.2 IPv6-first, IPv4-fallback)
16//!
17//! [`dial_candidates`] orders a provider's dialable addresses IPv6 first, then IPv4, then anything
18//! unresolvable — so a dialer walks the whole list and only reports failure once EVERY candidate
19//! has been tried. One unusable v6 candidate must never mask a working v4 one.
20
21use dig_dht::{CandidateAddr, ProviderRecord};
22use std::net::{IpAddr, SocketAddr};
23use thiserror::Error;
24
25/// Upper bound on dial candidates tried per provider, so a record padded with many addresses cannot
26/// turn one holder into an unbounded connect storm.
27pub const MAX_DIAL_CANDIDATES: usize = 4;
28
29/// Why a candidate address could not be turned into a dialable [`SocketAddr`].
30#[derive(Debug, Clone, PartialEq, Eq, Error)]
31pub enum AddrError {
32 /// The host is neither an IPv4 nor an IPv6 literal. DHT candidates are always literals (they are
33 /// *observed* socket addresses), so this means a malformed or hostname-bearing record — this
34 /// crate does not resolve DNS on the dial path.
35 #[error("candidate host {host:?} is not an IPv4/IPv6 literal")]
36 NotAnIpLiteral {
37 /// The offending host text, quoted in the message so a bad record is greppable in logs.
38 host: String,
39 },
40}
41
42/// Resolve one candidate to a dialable [`SocketAddr`].
43///
44/// Correct for IPv4, IPv6, and v4-mapped-IPv6 hosts alike, because the port is attached to a parsed
45/// [`IpAddr`] rather than to a formatted string (see the module docs).
46pub fn candidate_socket(addr: &CandidateAddr) -> Result<SocketAddr, AddrError> {
47 let ip: IpAddr = addr.host.parse().map_err(|_| AddrError::NotAnIpLiteral {
48 host: addr.host.clone(),
49 })?;
50 Ok(SocketAddr::new(ip, addr.port))
51}
52
53/// Render a candidate as `host:port`, bracketing an IPv6 literal so the text round-trips through
54/// [`str::parse::<SocketAddr>`] and reads unambiguously in logs.
55pub fn display(addr: &CandidateAddr) -> String {
56 match candidate_socket(addr) {
57 Ok(socket) => socket.to_string(),
58 // Not a literal: there is nothing to bracket, so show it verbatim rather than inventing syntax.
59 Err(_) => format!("{}:{}", addr.host, addr.port),
60 }
61}
62
63/// The provider's dialable candidates in dial order: **IPv6 first, then IPv4** (§5.2), then any
64/// candidate whose host is not a literal — capped at [`MAX_DIAL_CANDIDATES`].
65///
66/// Unresolvable candidates are kept (last) on purpose: a dialer that walks them reports a concrete
67/// per-candidate reason instead of silently pretending the provider had no address at all.
68pub fn dial_candidates(provider: &ProviderRecord) -> Vec<&CandidateAddr> {
69 let mut candidates: Vec<&CandidateAddr> = provider
70 .addresses
71 .iter()
72 .filter(|a| a.kind.is_dialable())
73 .collect();
74 candidates.sort_by_key(|a| match candidate_socket(a) {
75 Ok(SocketAddr::V6(_)) => 0,
76 Ok(SocketAddr::V4(_)) => 1,
77 Err(_) => 2,
78 });
79 candidates.truncate(MAX_DIAL_CANDIDATES);
80 candidates
81}
82
83#[cfg(test)]
84mod tests {
85 use super::*;
86 use dig_dht::{AddressKind, Key};
87 use dig_nat::PeerId;
88
89 fn record(addresses: Vec<CandidateAddr>) -> ProviderRecord {
90 ProviderRecord::new(
91 &Key::from_bytes([0xAB; 32]),
92 &PeerId::from_bytes([1; 32]),
93 addresses,
94 u64::MAX,
95 )
96 }
97
98 #[test]
99 fn resolves_v4_v6_and_v4_mapped_hosts() {
100 for host in ["10.0.0.1", "2001:db8::1", "::ffff:10.0.0.1"] {
101 let addr = CandidateAddr::direct(host, 9444);
102 let socket = candidate_socket(&addr).expect("literal host must resolve");
103 assert_eq!(socket.ip(), host.parse::<IpAddr>().unwrap());
104 assert_eq!(socket.port(), 9444);
105 }
106 }
107
108 #[test]
109 fn rejects_a_non_literal_host_with_a_named_reason() {
110 let err = candidate_socket(&CandidateAddr::direct("peer.example", 9444)).unwrap_err();
111 assert_eq!(
112 err,
113 AddrError::NotAnIpLiteral {
114 host: "peer.example".into()
115 }
116 );
117 }
118
119 #[test]
120 fn display_brackets_v6_and_leaves_v4_bare() {
121 assert_eq!(
122 display(&CandidateAddr::direct("10.0.0.1", 9444)),
123 "10.0.0.1:9444"
124 );
125 assert_eq!(
126 display(&CandidateAddr::direct("::ffff:10.0.0.1", 9444)),
127 "[::ffff:10.0.0.1]:9444"
128 );
129 // A rendered candidate must always parse back as a socket address.
130 assert!(display(&CandidateAddr::direct("2001:db8::1", 9444))
131 .parse::<SocketAddr>()
132 .is_ok());
133 }
134
135 #[test]
136 fn dial_order_is_v6_then_v4_then_unresolvable() {
137 let p = record(vec![
138 CandidateAddr::direct("10.0.0.1", 1),
139 CandidateAddr::direct("peer.example", 2),
140 CandidateAddr::direct("2001:db8::1", 3),
141 ]);
142 let hosts: Vec<&str> = dial_candidates(&p)
143 .iter()
144 .map(|a| a.host.as_str())
145 .collect();
146 assert_eq!(hosts, vec!["2001:db8::1", "10.0.0.1", "peer.example"]);
147 }
148
149 #[test]
150 fn dial_candidates_skip_relay_markers_and_stay_bounded() {
151 let mut addresses = vec![CandidateAddr::relay_marker()];
152 addresses.extend((0..10).map(|i| CandidateAddr::direct(format!("10.0.0.{i}"), 9444)));
153 let p = record(addresses);
154 let candidates = dial_candidates(&p);
155 assert_eq!(candidates.len(), MAX_DIAL_CANDIDATES);
156 assert!(candidates.iter().all(|a| a.kind == AddressKind::Direct));
157 }
158}