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). `0` for a
88    /// genuine IPv6 literal, `1` for an IPv4 literal (including an IPv4-mapped IPv6 address, which
89    /// [`Family::of`] correctly classifies as V4 — it is IPv4 reachability) OR a hostname (which
90    /// parses as neither and falls back with IPv4). Deriving the family here, rather than
91    /// hand-rolling an `is_ipv6` check, keeps dig-dht from drifting off the canonical contract.
92    fn family_rank(&self) -> u8 {
93        let family = self
94            .host
95            .parse::<IpAddr>()
96            .ok()
97            .map(|ip| Family::of(&SocketAddr::new(ip, self.port)));
98        match family {
99            Some(Family::V6) => 0,
100            Some(Family::V4) | None => 1,
101        }
102    }
103
104    /// Sort key for IPv6-first, then most-direct-first ordering: `(family_rank, kind_rank)`. The
105    /// family half comes from [`dig_ip::Family`] (see [`family_rank`](Self::family_rank)); the
106    /// dht-specific directness tiebreak stays [`AddressKind::rank`], so within one family the most
107    /// direct candidate sorts first.
108    fn family_then_kind_rank(&self) -> (u8, u8) {
109        (self.family_rank(), self.kind.rank())
110    }
111}
112
113/// Sort `addresses` **IPv6-first, then by [`AddressKind::rank`]** — the ecosystem-wide IPv6-first,
114/// IPv4-fallback rule for peer communication. Used by both [`ProviderRecord::new`] and
115/// [`crate::routing::Contact::new`] so provider and routing-table address lists share one ordering
116/// policy. This only reorders the list; the wire shape of each [`CandidateAddr`] is unchanged.
117pub(crate) fn sort_addresses_ipv6_first(addresses: &mut [CandidateAddr]) {
118    addresses.sort_by_key(CandidateAddr::family_then_kind_rank);
119}
120
121/// Maximum [`CandidateAddr`] entries kept per [`ProviderRecord`] / [`crate::routing::Contact`].
122///
123/// A record/contact carries candidate addresses so a finder can dial the holder; nothing on the
124/// wire or decode path previously bounded how many a single record could carry (only the overall
125/// 256 KiB frame did — [`crate::wire::MAX_FRAMED_BODY`]), so one frame could smuggle thousands of
126/// addresses that the victim would store, fold into its routing table, AND re-serve (cloned) to
127/// every querying peer — memory inflation plus bandwidth amplification (SPEC §5.5, §14). Eight is
128/// generous headroom over the four [`AddressKind`] variants (a conforming producer emits at most
129/// one address per kind per family) while remaining a small, cheap-to-clone constant.
130pub const MAX_ADDRESSES_PER_RECORD: usize = 8;
131
132/// Sort `addresses` **IPv6-first-then-rank** (see [`sort_addresses_ipv6_first`]) and then truncate
133/// to [`MAX_ADDRESSES_PER_RECORD`], so the most-preferred candidates are the ones kept when a list
134/// exceeds the cap. This is the one admission point both the constructors ([`ProviderRecord::new`],
135/// [`crate::routing::Contact::new`]) and the wire-decode boundary (`handle_request_from`'s
136/// `AddProvider` arm, and contacts folded in from lookup responses) MUST call before accepting an
137/// address list from any source that did not already go through it — a `ProviderRecord` /
138/// `Contact` deserialized directly from the wire bypasses the constructors entirely (their fields
139/// are public), so capping only in `new` would not close the untrusted-input path.
140pub(crate) fn sort_and_cap_addresses(addresses: &mut Vec<CandidateAddr>) {
141    sort_addresses_ipv6_first(addresses);
142    addresses.truncate(MAX_ADDRESSES_PER_RECORD);
143}
144
145/// The DHT's stored value: peer `provider_peer_id` holds the content whose key is `content_key`,
146/// reachable at `addresses`, until `expires_at`.
147///
148/// - `content_key` is the 64-hex [`Key`](crate::Key) the content id hashed to — the DHT stores by
149///   key, not by the (larger, granularity-tagged) content id, so a record is compact and the store
150///   is a pure key→providers map.
151/// - `provider_peer_id` is the 64-hex `peer_id` of the holder; a finder builds a `PeerTarget` from
152///   it plus `addresses` and connects via dig-nat.
153/// - `expires_at` is absolute Unix seconds; a record past its expiry is treated as absent and GC'd.
154///   The holder republishes (a fresh record with a new `expires_at`) before expiry to stay findable.
155#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
156pub struct ProviderRecord {
157    /// The content key (64-hex) this record provides for — the [`Key`](crate::Key) a content id
158    /// hashed to.
159    pub content_key: String,
160    /// The holder's `peer_id` (64-hex).
161    pub provider_peer_id: String,
162    /// Candidate addresses to reach the holder. Ordered IPv6-first, then most-direct-first by
163    /// [`AddressKind::rank`] when built via [`ProviderRecord::new`]; a record deserialized directly
164    /// from the wire (bypassing `new`) is not guaranteed sorted, so a consumer that cannot assume a
165    /// conforming producer should still sort defensively.
166    pub addresses: Vec<CandidateAddr>,
167    /// Absolute expiry (Unix seconds). A record at/after this time is stale.
168    pub expires_at: u64,
169}
170
171impl ProviderRecord {
172    /// Build a record: peer `provider` holds `content_key`, reachable at `addresses`, until
173    /// `expires_at` (absolute Unix seconds).
174    pub fn new(
175        content_key: &crate::key::Key,
176        provider: &PeerId,
177        mut addresses: Vec<CandidateAddr>,
178        expires_at: u64,
179    ) -> Self {
180        sort_and_cap_addresses(&mut addresses);
181        ProviderRecord {
182            content_key: content_key.to_hex(),
183            provider_peer_id: provider.to_hex(),
184            addresses,
185            expires_at,
186        }
187    }
188
189    /// The provider's `peer_id` decoded from the 64-hex field, or `None` if malformed.
190    pub fn provider_peer_id(&self) -> Option<PeerId> {
191        PeerId::from_hex(&self.provider_peer_id)
192    }
193
194    /// Whether this record is expired at `now` (Unix seconds) — stale records are dropped on read.
195    pub fn is_expired(&self, now: u64) -> bool {
196        now >= self.expires_at
197    }
198
199    /// The IPv6-preferred, most-direct dialable candidate address, if any — the address a finder
200    /// dials first. `addresses` is already IPv6-first-then-rank sorted (`sort_addresses_ipv6_first`),
201    /// so this is simply the first dialable entry.
202    pub fn best_address(&self) -> Option<&CandidateAddr> {
203        self.addresses.iter().find(|a| a.kind.is_dialable())
204    }
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210    use crate::key::Key;
211
212    fn pid(b: u8) -> PeerId {
213        PeerId::from_bytes([b; 32])
214    }
215
216    #[test]
217    fn record_round_trips_through_json() {
218        let key = Key::from_bytes([0xAB; 32]);
219        let rec = ProviderRecord::new(
220            &key,
221            &pid(0x07),
222            vec![CandidateAddr::direct("203.0.113.7", 9444)],
223            1_000,
224        );
225        let json = serde_json::to_string(&rec).unwrap();
226        let back: ProviderRecord = serde_json::from_str(&json).unwrap();
227        assert_eq!(rec, back);
228        assert_eq!(back.provider_peer_id().unwrap(), pid(0x07));
229        assert_eq!(back.content_key, key.to_hex());
230    }
231
232    #[test]
233    fn ttl_expiry() {
234        let rec = ProviderRecord::new(&Key::from_bytes([0u8; 32]), &pid(1), vec![], 100);
235        assert!(!rec.is_expired(99));
236        assert!(rec.is_expired(100));
237        assert!(rec.is_expired(101));
238    }
239
240    #[test]
241    fn address_kind_wire_tokens_are_lowercase() {
242        assert_eq!(
243            serde_json::to_string(&AddressKind::Direct).unwrap(),
244            "\"direct\""
245        );
246        assert_eq!(
247            serde_json::to_string(&AddressKind::Reflexive).unwrap(),
248            "\"reflexive\""
249        );
250        assert_eq!(
251            serde_json::to_string(&AddressKind::Mapped).unwrap(),
252            "\"mapped\""
253        );
254        assert_eq!(
255            serde_json::to_string(&AddressKind::Relay).unwrap(),
256            "\"relay\""
257        );
258    }
259
260    #[test]
261    fn best_address_prefers_most_direct() {
262        let key = Key::from_bytes([0u8; 32]);
263        let rec = ProviderRecord::new(
264            &key,
265            &pid(1),
266            vec![
267                CandidateAddr {
268                    host: "r".into(),
269                    port: 1,
270                    kind: AddressKind::Reflexive,
271                },
272                CandidateAddr::direct("d", 2),
273                CandidateAddr::relay_marker(),
274            ],
275            10,
276        );
277        assert_eq!(rec.best_address().unwrap().kind, AddressKind::Direct);
278    }
279
280    #[test]
281    fn best_address_none_when_only_relay() {
282        let key = Key::from_bytes([0u8; 32]);
283        let rec = ProviderRecord::new(&key, &pid(1), vec![CandidateAddr::relay_marker()], 10);
284        assert!(rec.best_address().is_none());
285    }
286
287    #[test]
288    fn address_rank_ordering() {
289        assert!(AddressKind::Direct.rank() < AddressKind::Mapped.rank());
290        assert!(AddressKind::Mapped.rank() < AddressKind::Reflexive.rank());
291        assert!(AddressKind::Reflexive.rank() < AddressKind::Relay.rank());
292        assert!(!AddressKind::Relay.is_dialable());
293        assert!(AddressKind::Direct.is_dialable());
294    }
295
296    #[test]
297    fn provider_record_new_sorts_addresses_ipv6_first() {
298        // Fed in IPv4-first order; the stored list must come out IPv6-first, then by rank.
299        let key = Key::from_bytes([0u8; 32]);
300        let rec = ProviderRecord::new(
301            &key,
302            &pid(1),
303            vec![
304                CandidateAddr::direct("203.0.113.7", 9444), // IPv4 direct
305                CandidateAddr::direct("2001:db8::1", 9444), // IPv6 direct
306                CandidateAddr {
307                    host: "198.51.100.2".into(),
308                    port: 1,
309                    kind: AddressKind::Reflexive,
310                }, // IPv4 reflexive
311                CandidateAddr {
312                    host: "2001:db8::2".into(),
313                    port: 1,
314                    kind: AddressKind::Reflexive,
315                }, // IPv6 reflexive
316            ],
317            10,
318        );
319        let hosts: Vec<&str> = rec.addresses.iter().map(|a| a.host.as_str()).collect();
320        assert_eq!(
321            hosts,
322            vec!["2001:db8::1", "2001:db8::2", "203.0.113.7", "198.51.100.2"],
323            "addresses must be IPv6-first, then ranked by AddressKind"
324        );
325    }
326
327    #[test]
328    fn family_key_derives_from_dig_ip_family() {
329        // The FAMILY half of the sort key comes from `dig_ip::Family`, the single ecosystem source
330        // of truth — not a hand-rolled `is_ipv6` heuristic. The load-bearing proof is the
331        // IPv4-mapped IPv6 case: `dig_ip::Family::of` classifies `::ffff:a.b.c.d` as V4 (it is IPv4
332        // reachability), so it must sort with IPv4, AFTER a genuine IPv6 address of the same kind. A
333        // `host.parse::<IpAddr>()`-based family key would have (wrongly) treated it as IPv6.
334        let key = Key::from_bytes([0u8; 32]);
335        let rec = ProviderRecord::new(
336            &key,
337            &pid(1),
338            vec![
339                CandidateAddr::direct("::ffff:203.0.113.9", 9444), // IPv4-mapped → V4 per dig-ip
340                CandidateAddr::direct("2001:db8::1", 9444),        // genuine IPv6 → V6
341            ],
342            10,
343        );
344        let hosts: Vec<&str> = rec.addresses.iter().map(|a| a.host.as_str()).collect();
345        assert_eq!(
346            hosts,
347            vec!["2001:db8::1", "::ffff:203.0.113.9"],
348            "an IPv4-mapped IPv6 address must sort as V4 (dig_ip::Family), after a genuine IPv6"
349        );
350    }
351
352    #[test]
353    fn directness_kind_rank_preserved_as_tiebreak_within_a_family() {
354        // Within ONE address family the dht-specific most-direct-first `AddressKind::rank` tiebreak
355        // MUST survive the migration to dig-ip family keying: same family, different directness →
356        // Direct before Mapped before Reflexive.
357        let key = Key::from_bytes([0u8; 32]);
358        let rec = ProviderRecord::new(
359            &key,
360            &pid(1),
361            vec![
362                CandidateAddr {
363                    host: "2001:db8::3".into(),
364                    port: 1,
365                    kind: AddressKind::Reflexive,
366                },
367                CandidateAddr {
368                    host: "2001:db8::2".into(),
369                    port: 1,
370                    kind: AddressKind::Mapped,
371                },
372                CandidateAddr::direct("2001:db8::1", 9444),
373            ],
374            10,
375        );
376        let hosts: Vec<&str> = rec.addresses.iter().map(|a| a.host.as_str()).collect();
377        assert_eq!(
378            hosts,
379            vec!["2001:db8::1", "2001:db8::2", "2001:db8::3"],
380            "within one family, addresses must stay ordered by AddressKind::rank (most-direct first)"
381        );
382    }
383
384    #[test]
385    fn best_address_prefers_ipv6_over_ipv4_at_same_rank() {
386        let key = Key::from_bytes([0u8; 32]);
387        let rec = ProviderRecord::new(
388            &key,
389            &pid(1),
390            vec![
391                CandidateAddr::direct("203.0.113.7", 9444), // IPv4 direct, fed first
392                CandidateAddr::direct("2001:db8::1", 9444), // IPv6 direct, fed second
393            ],
394            10,
395        );
396        assert_eq!(rec.best_address().unwrap().host, "2001:db8::1");
397    }
398
399    // ---- Address-list cap (MEDIUM: no cap on addresses[], SECURITY_AUDIT_P2P.md #179) ----
400
401    #[test]
402    fn provider_record_new_caps_addresses_at_the_constant() {
403        // Feed far more than the cap — a hostile/misconfigured caller must never make a
404        // constructed record carry an unbounded address list.
405        let key = Key::from_bytes([0u8; 32]);
406        let many: Vec<CandidateAddr> = (0..1000)
407            .map(|i| CandidateAddr::direct(format!("203.0.113.{}", i % 255), 9444))
408            .collect();
409        let rec = ProviderRecord::new(&key, &pid(1), many, 10);
410        assert_eq!(rec.addresses.len(), MAX_ADDRESSES_PER_RECORD);
411    }
412
413    #[test]
414    fn provider_record_new_cap_keeps_most_preferred_after_sort() {
415        // The cap must apply AFTER the IPv6-first-then-rank sort, so truncation drops the LEAST
416        // preferred candidates, not an arbitrary prefix of the input order.
417        let key = Key::from_bytes([0u8; 32]);
418        let mut addrs: Vec<CandidateAddr> = Vec::new();
419        // One preferred IPv6 direct address that must survive the cap...
420        addrs.push(CandidateAddr::direct("2001:db8::1", 9444));
421        // ...buried behind far more than the cap worth of low-preference IPv4 relay markers.
422        for i in 0..1000u32 {
423            addrs.push(CandidateAddr {
424                host: format!("198.51.100.{}", i % 255),
425                port: 1,
426                kind: AddressKind::Relay,
427            });
428        }
429        let rec = ProviderRecord::new(&key, &pid(1), addrs, 10);
430        assert_eq!(rec.addresses.len(), MAX_ADDRESSES_PER_RECORD);
431        assert_eq!(
432            rec.addresses[0].host, "2001:db8::1",
433            "the single most-preferred (IPv6 direct) candidate must survive truncation"
434        );
435    }
436}