Skip to main content

dig_pex/
entry.rs

1//! The **peer entry** — the unit of exchange (SPEC §3). It is the L7 `PeerRecord` shape extended
2//! with a `flags` list, carrying a `peer_id`, candidate `addresses`, the `network_id`, a `last_seen`
3//! Unix-seconds timestamp, a `via` provenance, and per-peer capability `flags`.
4//!
5//! ## Byte-compatibility
6//!
7//! [`Address`] (`{ host, port, kind }`) is byte-compatible with the L7 `dig.getPeers` addresses and
8//! the dig-nat / dig-gossip / dig-dht `Contact` address shape — same JSON field names and the same
9//! `kind` tokens (`direct` | `mapped` | `reflexive` | `relay`). It is mirrored here (rather than
10//! importing those crates) to keep the PEX dependency surface minimal; the wire form MUST stay
11//! identical so a returned entry drops straight into a dial target.
12//!
13//! ## Tolerant decode, strict validation
14//!
15//! Inbound entries decode **tolerantly**: an unrecognized `kind` or `via` token deserializes to the
16//! [`AddressKind::Unknown`] / [`Provenance::Unknown`] catch-all rather than failing the whole
17//! message, and every field has a default. This is what makes a malformed entry *skipped, not fatal*
18//! (SPEC §3.3, §7.3): [`PeerEntry::validate`] decides keep-or-skip; a broken token never aborts the
19//! sibling entries around it.
20
21use serde::{Deserialize, Serialize};
22
23use crate::caps::{PEX_MAX_ADDRESSES, PEX_MAX_ENTRY_AGE, PEX_MAX_FLAGS, PEX_MAX_FLAG_LEN};
24use crate::error::EntrySkip;
25
26/// How a candidate address was learned — the L7 `dig.getPeers` `addresses[].kind` tokens (SPEC §3.1).
27/// The lowercase serde spelling is the frozen wire form.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
29#[serde(rename_all = "lowercase")]
30pub enum AddressKind {
31    /// A directly reachable address (publicly routable or port-forwarded).
32    Direct,
33    /// A UPnP / NAT-PMP / PCP-mapped external address.
34    Mapped,
35    /// A STUN-discovered public reflexive address.
36    Reflexive,
37    /// Reachable through the relay (no direct candidate).
38    Relay,
39    /// An unrecognized token — the catch-all so an unknown `kind` skips the entry (SPEC §3.3)
40    /// instead of aborting the message. Never emitted by a conformant sender.
41    #[serde(other)]
42    #[default]
43    Unknown,
44}
45
46impl AddressKind {
47    /// The frozen lowercase wire token (or `"unknown"` for the catch-all).
48    #[must_use]
49    pub fn as_str(self) -> &'static str {
50        match self {
51            AddressKind::Direct => "direct",
52            AddressKind::Mapped => "mapped",
53            AddressKind::Reflexive => "reflexive",
54            AddressKind::Relay => "relay",
55            AddressKind::Unknown => "unknown",
56        }
57    }
58
59    /// Whether this is one of the four registered tokens (i.e. not the `Unknown` catch-all).
60    #[must_use]
61    pub fn is_registered(self) -> bool {
62        !matches!(self, AddressKind::Unknown)
63    }
64}
65
66/// The advertiser's **provenance** for an entry (SPEC §3.1, §8.1) — how it knows the peer first-hand.
67/// There is deliberately no `"pex"` token: an entry known only via PEX has no legitimate `via` to
68/// claim, which is what stops re-gossip amplification (SPEC §8.1).
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
70#[serde(rename_all = "lowercase")]
71pub enum Provenance {
72    /// Learned from a direct mTLS-verified connection to the peer.
73    Direct,
74    /// Learned from a relayed mTLS-verified connection to the peer.
75    Relay,
76    /// Learned from this participant's own introducer / relay registration surface.
77    Introducer,
78    /// An unrecognized token — the catch-all so an unknown `via` skips the entry (SPEC §3.3).
79    /// Never emitted by a conformant sender.
80    #[serde(other)]
81    #[default]
82    Unknown,
83}
84
85impl Provenance {
86    /// The frozen lowercase wire token (or `"unknown"` for the catch-all).
87    #[must_use]
88    pub fn as_str(self) -> &'static str {
89        match self {
90            Provenance::Direct => "direct",
91            Provenance::Relay => "relay",
92            Provenance::Introducer => "introducer",
93            Provenance::Unknown => "unknown",
94        }
95    }
96
97    /// Whether this is one of the three registered provenance tokens.
98    #[must_use]
99    pub fn is_registered(self) -> bool {
100        !matches!(self, Provenance::Unknown)
101    }
102}
103
104/// One candidate address for a peer: `{ host, port, kind }` (SPEC §3.1). Byte-compatible with the L7
105/// `dig.getPeers` / DHT `Contact` address shape.
106#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
107pub struct Address {
108    /// IPv4/IPv6 literal or hostname. MUST be non-empty (SPEC §3.3).
109    #[serde(default)]
110    pub host: String,
111    /// P2P port, 1–65535. A `port` of 0 skips the entry (SPEC §3.3).
112    #[serde(default)]
113    pub port: u16,
114    /// How this address was learned.
115    #[serde(default)]
116    pub kind: AddressKind,
117}
118
119impl Address {
120    /// A directly-dialable candidate (public / port-forwarded / discovered).
121    #[must_use]
122    pub fn direct(host: impl Into<String>, port: u16) -> Self {
123        Address {
124            host: host.into(),
125            port,
126            kind: AddressKind::Direct,
127        }
128    }
129
130    /// A candidate of a specific [`AddressKind`].
131    #[must_use]
132    pub fn new(host: impl Into<String>, port: u16, kind: AddressKind) -> Self {
133        Address {
134            host: host.into(),
135            port,
136            kind,
137        }
138    }
139
140    /// Whether this address is well-formed for a receiver (non-empty host, non-zero port, a
141    /// registered `kind`) — SPEC §3.3.
142    #[must_use]
143    pub fn is_valid(&self) -> bool {
144        !self.host.is_empty() && self.port != 0 && self.kind.is_registered()
145    }
146}
147
148/// The unit of exchange — a peer entry (SPEC §3). Constructed via [`PeerEntry::new`] + the builder
149/// methods for outgoing advertisements; decoded tolerantly for inbound validation.
150#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
151pub struct PeerEntry {
152    /// The advertised peer's mTLS identity, `<64hex>`.
153    #[serde(default)]
154    pub peer_id: String,
155    /// Candidate addresses, most-direct-first. MAY be empty (reachable only via shared
156    /// infrastructure, e.g. relay rendezvous by `peer_id`; see the `relay-only` flag).
157    #[serde(default)]
158    pub addresses: Vec<Address>,
159    /// The network the peer belongs to. MUST equal the link's network.
160    #[serde(default)]
161    pub network_id: String,
162    /// Unix seconds when the advertiser last had first-hand evidence of the peer (SPEC §8.2).
163    #[serde(default)]
164    pub last_seen: u64,
165    /// The advertiser's provenance for this entry (SPEC §8.1).
166    #[serde(default)]
167    pub via: Provenance,
168    /// Per-peer capability flags (SPEC §3.2). Optional; defaults to empty.
169    #[serde(default)]
170    pub flags: Vec<String>,
171}
172
173impl PeerEntry {
174    /// A new entry for `peer_id` on `network_id`, last seen at `last_seen` (Unix seconds), with
175    /// provenance `via`. Add addresses/flags with the builder methods.
176    #[must_use]
177    pub fn new(
178        peer_id: impl Into<String>,
179        network_id: impl Into<String>,
180        last_seen: u64,
181        via: Provenance,
182    ) -> Self {
183        PeerEntry {
184            peer_id: peer_id.into(),
185            addresses: Vec::new(),
186            network_id: network_id.into(),
187            last_seen,
188            via,
189            flags: Vec::new(),
190        }
191    }
192
193    /// Builder: append a candidate address.
194    #[must_use]
195    pub fn with_address(mut self, addr: Address) -> Self {
196        self.addresses.push(addr);
197        self
198    }
199
200    /// Builder: append a capability flag token.
201    #[must_use]
202    pub fn with_flag(mut self, flag: impl Into<String>) -> Self {
203        self.flags.push(flag.into());
204        self
205    }
206
207    /// Validate this entry against the receiver's link context (SPEC §3.3). Returns the reason a
208    /// conformant receiver skips it, or `Ok(())` to keep it. Skipping is silent — no strike.
209    pub fn validate(&self, ctx: &ValidateCtx<'_>) -> Result<(), EntrySkip> {
210        if !is_hex64(&self.peer_id) {
211            return Err(EntrySkip::BadPeerId);
212        }
213        if self.peer_id == ctx.receiver_peer_id || self.peer_id == ctx.sender_peer_id {
214            return Err(EntrySkip::SelfOrPartner);
215        }
216        if self.addresses.len() > PEX_MAX_ADDRESSES {
217            return Err(EntrySkip::TooManyAddresses);
218        }
219        if self.addresses.iter().any(|a| !a.is_valid()) {
220            return Err(EntrySkip::BadAddress);
221        }
222        if self.flags.len() > PEX_MAX_FLAGS || self.flags.iter().any(|f| f.len() > PEX_MAX_FLAG_LEN)
223        {
224            return Err(EntrySkip::TooManyFlags);
225        }
226        if self.network_id != ctx.network_id {
227            return Err(EntrySkip::NetworkMismatch);
228        }
229        if !self.via.is_registered() {
230            return Err(EntrySkip::BadVia);
231        }
232        // A `last_seen` in the future is clamped by the caller (see `clamped`); only an entry too far
233        // in the PAST is skipped.
234        if self.last_seen < ctx.now_secs && ctx.now_secs - self.last_seen > PEX_MAX_ENTRY_AGE {
235            return Err(EntrySkip::TooOld);
236        }
237        Ok(())
238    }
239
240    /// A copy with a future `last_seen` clamped to `now_secs` (SPEC §3.3 — a receiver SHOULD clamp a
241    /// `last_seen` in the future to its own clock).
242    #[must_use]
243    pub fn clamped(&self, now_secs: u64) -> PeerEntry {
244        let mut e = self.clone();
245        if e.last_seen > now_secs {
246            e.last_seen = now_secs;
247        }
248        e
249    }
250
251    /// The per-link **advertised-content fingerprint** — a stable string over the addresses and flags
252    /// **excluding `last_seen`** (SPEC §9.1), so heartbeat churn (a fresher `last_seen` alone) never
253    /// re-advertises an unchanged peer. Two entries with the same fingerprint are "the same
254    /// advertisement" for delta purposes.
255    ///
256    /// This allocates (a `Vec<String>` per call plus the final formatted `String`) and is intended
257    /// for display/debugging/tests. The delta hot path uses the allocation-free
258    /// [`fingerprint_hash`](Self::fingerprint_hash) instead (#179 MED optimization).
259    #[must_use]
260    pub fn fingerprint(&self) -> String {
261        let mut addrs: Vec<String> = self
262            .addresses
263            .iter()
264            .map(|a| format!("{}|{}|{}", a.host, a.port, a.kind.as_str()))
265            .collect();
266        addrs.sort();
267        let mut flags = self.flags.clone();
268        flags.sort();
269        format!("{}#{}", addrs.join(","), flags.join(","))
270    }
271
272    /// The allocation-free equivalent of [`fingerprint`](Self::fingerprint): a 64-bit hash over the
273    /// same canonical content (addresses + flags, sorted, **excluding `last_seen`**) with the same
274    /// equality semantics — two entries with equal `fingerprint()` strings MUST have equal
275    /// `fingerprint_hash()` values (and vice versa for practical purposes; a hash collision is
276    /// possible but not a correctness concern for this delta-suppression use). Used as the `told`-map
277    /// value (SPEC §9.1) so the per-tick, per-link delta comparison is a `Copy`, allocation-free `u64`
278    /// equality check instead of building + sorting + formatting a `String` per advertisable entry
279    /// per link per tick (#179 MED optimization).
280    ///
281    /// Sorts addresses/flags by **reference** (`Vec<&Address>` / `Vec<&str>`, no cloning) before
282    /// feeding a stable field-separated byte stream to the hasher, so the result is independent of
283    /// input order while never allocating an intermediate `String`.
284    #[must_use]
285    pub fn fingerprint_hash(&self) -> u64 {
286        use std::hash::{Hash, Hasher};
287
288        let mut addrs: Vec<&Address> = self.addresses.iter().collect();
289        addrs.sort_by(|a, b| {
290            (a.host.as_str(), a.port, a.kind.as_str()).cmp(&(
291                b.host.as_str(),
292                b.port,
293                b.kind.as_str(),
294            ))
295        });
296        let mut flags: Vec<&str> = self.flags.iter().map(String::as_str).collect();
297        flags.sort_unstable();
298
299        // A fixed-seed hasher (not HashMap's randomized default) so the result is reproducible within
300        // and across engine instances in the same process run — only ever compared in-memory, never
301        // persisted or sent over the wire, so cross-process/version stability is not required.
302        let mut hasher = std::collections::hash_map::DefaultHasher::new();
303        addrs.len().hash(&mut hasher);
304        for a in &addrs {
305            a.host.hash(&mut hasher);
306            a.port.hash(&mut hasher);
307            a.kind.as_str().hash(&mut hasher);
308        }
309        flags.len().hash(&mut hasher);
310        for f in &flags {
311            f.hash(&mut hasher);
312        }
313        hasher.finish()
314    }
315}
316
317/// The receiver-side context an inbound entry is validated against (SPEC §3.3).
318#[derive(Debug, Clone, Copy)]
319pub struct ValidateCtx<'a> {
320    /// The receiver's own `peer_id` — an entry advertising it is skipped (SPEC §5.4).
321    pub receiver_peer_id: &'a str,
322    /// The link partner's (sender's) `peer_id` — an entry advertising it is skipped (SPEC §5.4).
323    pub sender_peer_id: &'a str,
324    /// The link's network — an entry on a different `network_id` is skipped.
325    pub network_id: &'a str,
326    /// The receiver's current time in Unix seconds — for the freshness check.
327    pub now_secs: u64,
328}
329
330/// Whether `s` is exactly 64 lowercase hexadecimal characters (`<64hex>`, SPEC §2).
331#[must_use]
332pub fn is_hex64(s: &str) -> bool {
333    s.len() == 64
334        && s.bytes()
335            .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
336}
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341
342    fn hex(b: u8) -> String {
343        format!("{b:02x}").repeat(32)
344    }
345
346    fn ctx<'a>(recv: &'a str, send: &'a str, net: &'a str, now: u64) -> ValidateCtx<'a> {
347        ValidateCtx {
348            receiver_peer_id: recv,
349            sender_peer_id: send,
350            network_id: net,
351            now_secs: now,
352        }
353    }
354
355    #[test]
356    fn hex64_recognizer() {
357        assert!(is_hex64(&"a".repeat(64)));
358        assert!(is_hex64(&hex(0xab)));
359        assert!(!is_hex64(&"a".repeat(63)));
360        assert!(!is_hex64(&"A".repeat(64))); // uppercase not allowed
361        assert!(!is_hex64(&"g".repeat(64))); // non-hex
362    }
363
364    #[test]
365    fn kind_and_via_tokens_are_frozen_lowercase() {
366        assert_eq!(
367            serde_json::to_string(&AddressKind::Direct).unwrap(),
368            "\"direct\""
369        );
370        assert_eq!(
371            serde_json::to_string(&AddressKind::Relay).unwrap(),
372            "\"relay\""
373        );
374        assert_eq!(
375            serde_json::to_string(&Provenance::Introducer).unwrap(),
376            "\"introducer\""
377        );
378    }
379
380    #[test]
381    fn unknown_kind_and_via_decode_to_catch_all() {
382        let a: Address = serde_json::from_str(r#"{"host":"h","port":1,"kind":"quantum"}"#).unwrap();
383        assert_eq!(a.kind, AddressKind::Unknown);
384        let e: PeerEntry = serde_json::from_str(
385            r#"{"peer_id":"x","addresses":[],"network_id":"n","last_seen":1,"via":"teleport"}"#,
386        )
387        .unwrap();
388        assert_eq!(e.via, Provenance::Unknown);
389    }
390
391    #[test]
392    fn valid_entry_passes() {
393        let e = PeerEntry::new(hex(0x07), "mainnet", 1000, Provenance::Direct)
394            .with_address(Address::direct("203.0.113.7", 9444))
395            .with_flag("storage");
396        assert!(e
397            .validate(&ctx(&hex(0x01), &hex(0x02), "mainnet", 1000))
398            .is_ok());
399    }
400
401    #[test]
402    fn skip_reasons_match_spec() {
403        let recv = hex(0x01);
404        let send = hex(0x02);
405        // bad peer_id
406        let e = PeerEntry::new("nothex", "mainnet", 10, Provenance::Direct);
407        assert_eq!(
408            e.validate(&ctx(&recv, &send, "mainnet", 10)),
409            Err(EntrySkip::BadPeerId)
410        );
411        // self / partner
412        let e = PeerEntry::new(recv.clone(), "mainnet", 10, Provenance::Direct);
413        assert_eq!(
414            e.validate(&ctx(&recv, &send, "mainnet", 10)),
415            Err(EntrySkip::SelfOrPartner)
416        );
417        let e = PeerEntry::new(send.clone(), "mainnet", 10, Provenance::Direct);
418        assert_eq!(
419            e.validate(&ctx(&recv, &send, "mainnet", 10)),
420            Err(EntrySkip::SelfOrPartner)
421        );
422        // bad address (port 0)
423        let e = PeerEntry::new(hex(0x07), "mainnet", 10, Provenance::Direct)
424            .with_address(Address::new("h", 0, AddressKind::Direct));
425        assert_eq!(
426            e.validate(&ctx(&recv, &send, "mainnet", 10)),
427            Err(EntrySkip::BadAddress)
428        );
429        // unknown kind
430        let e = PeerEntry::new(hex(0x07), "mainnet", 10, Provenance::Direct)
431            .with_address(Address::new("h", 1, AddressKind::Unknown));
432        assert_eq!(
433            e.validate(&ctx(&recv, &send, "mainnet", 10)),
434            Err(EntrySkip::BadAddress)
435        );
436        // network mismatch
437        let e = PeerEntry::new(hex(0x07), "testnet", 10, Provenance::Direct);
438        assert_eq!(
439            e.validate(&ctx(&recv, &send, "mainnet", 10)),
440            Err(EntrySkip::NetworkMismatch)
441        );
442        // bad via
443        let e = PeerEntry::new(hex(0x07), "mainnet", 10, Provenance::Unknown);
444        assert_eq!(
445            e.validate(&ctx(&recv, &send, "mainnet", 10)),
446            Err(EntrySkip::BadVia)
447        );
448        // too old
449        let e = PeerEntry::new(hex(0x07), "mainnet", 10, Provenance::Direct);
450        assert_eq!(
451            e.validate(&ctx(&recv, &send, "mainnet", 2000)),
452            Err(EntrySkip::TooOld)
453        );
454    }
455
456    #[test]
457    fn too_many_addresses_and_flags() {
458        let recv = hex(0x01);
459        let send = hex(0x02);
460        let mut e = PeerEntry::new(hex(0x07), "mainnet", 10, Provenance::Direct);
461        for i in 0..9 {
462            e = e.with_address(Address::direct("h", 1000 + i));
463        }
464        assert_eq!(
465            e.validate(&ctx(&recv, &send, "mainnet", 10)),
466            Err(EntrySkip::TooManyAddresses)
467        );
468
469        let mut e = PeerEntry::new(hex(0x07), "mainnet", 10, Provenance::Direct);
470        for i in 0..9 {
471            e = e.with_flag(format!("f{i}"));
472        }
473        assert_eq!(
474            e.validate(&ctx(&recv, &send, "mainnet", 10)),
475            Err(EntrySkip::TooManyFlags)
476        );
477    }
478
479    #[test]
480    fn future_last_seen_is_clamped_not_skipped() {
481        let recv = hex(0x01);
482        let send = hex(0x02);
483        let e = PeerEntry::new(hex(0x07), "mainnet", 5000, Provenance::Direct);
484        // now=1000, last_seen=5000 (future) — valid, and clamped to now.
485        assert!(e.validate(&ctx(&recv, &send, "mainnet", 1000)).is_ok());
486        assert_eq!(e.clamped(1000).last_seen, 1000);
487        assert_eq!(e.clamped(6000).last_seen, 5000);
488    }
489
490    #[test]
491    fn fingerprint_ignores_last_seen_but_tracks_addresses_and_flags() {
492        let a = PeerEntry::new(hex(0x07), "mainnet", 100, Provenance::Direct)
493            .with_address(Address::direct("h", 1))
494            .with_flag("storage");
495        let a2 = PeerEntry::new(hex(0x07), "mainnet", 999, Provenance::Direct)
496            .with_address(Address::direct("h", 1))
497            .with_flag("storage");
498        assert_eq!(
499            a.fingerprint(),
500            a2.fingerprint(),
501            "last_seen must not affect fingerprint"
502        );
503        let b = a2.clone().with_flag("holepunch");
504        assert_ne!(
505            a.fingerprint(),
506            b.fingerprint(),
507            "a flag change must change fingerprint"
508        );
509    }
510
511    /// MEDIUM finding (#179): `fingerprint_hash()` is the allocation-free hot-path equality check
512    /// used in the delta loop (`told` stores this hash, not the `String` fingerprint). It MUST agree
513    /// with `fingerprint()`'s equality semantics: same addresses+flags (order-independent) and
514    /// `last_seen`-independence hash equal; a real content change hashes different.
515    #[test]
516    fn fingerprint_hash_matches_fingerprint_equality_semantics() {
517        let a = PeerEntry::new(hex(0x07), "mainnet", 100, Provenance::Direct)
518            .with_address(Address::direct("h", 1))
519            .with_flag("storage");
520        let a2 = PeerEntry::new(hex(0x07), "mainnet", 999, Provenance::Direct)
521            .with_address(Address::direct("h", 1))
522            .with_flag("storage");
523        assert_eq!(
524            a.fingerprint_hash(),
525            a2.fingerprint_hash(),
526            "last_seen must not affect fingerprint_hash"
527        );
528        assert_eq!(
529            a.fingerprint() == a2.fingerprint(),
530            a.fingerprint_hash() == a2.fingerprint_hash(),
531            "fingerprint_hash must agree with fingerprint on equality"
532        );
533
534        let b = a2.clone().with_flag("holepunch");
535        assert_ne!(
536            a.fingerprint_hash(),
537            b.fingerprint_hash(),
538            "a flag change must change fingerprint_hash"
539        );
540        assert_eq!(
541            a.fingerprint() == b.fingerprint(),
542            a.fingerprint_hash() == b.fingerprint_hash(),
543            "fingerprint_hash must agree with fingerprint on inequality"
544        );
545
546        // Multiple addresses/flags supplied in a different insertion order must still hash equal
547        // (the canonical form sorts both before hashing, same as `fingerprint()`).
548        let c = PeerEntry::new(hex(0x08), "mainnet", 1, Provenance::Direct)
549            .with_address(Address::direct("h1", 1))
550            .with_address(Address::direct("h2", 2))
551            .with_flag("storage")
552            .with_flag("holepunch");
553        let d = PeerEntry::new(hex(0x08), "mainnet", 2, Provenance::Direct)
554            .with_address(Address::direct("h2", 2))
555            .with_address(Address::direct("h1", 1))
556            .with_flag("holepunch")
557            .with_flag("storage");
558        assert_eq!(
559            c.fingerprint_hash(),
560            d.fingerprint_hash(),
561            "address/flag insertion order must not affect fingerprint_hash"
562        );
563        assert_eq!(c.fingerprint(), d.fingerprint());
564    }
565
566    #[test]
567    fn entry_round_trips_through_json() {
568        let e = PeerEntry::new(hex(0x07), "mainnet", 1_719_763_200, Provenance::Direct)
569            .with_address(Address::direct("203.0.113.7", 9444))
570            .with_flag("storage")
571            .with_flag("holepunch");
572        let json = serde_json::to_string(&e).unwrap();
573        assert!(json.contains("\"peer_id\":"));
574        assert!(json.contains("\"via\":\"direct\""));
575        assert!(json.contains("\"kind\":\"direct\""));
576        let back: PeerEntry = serde_json::from_str(&json).unwrap();
577        assert_eq!(e, back);
578    }
579}