Skip to main content

dig_dht/
wire.rs

1//! The DHT RPC wire — the four request/response messages, `type`-tagged JSON, framed as a `u32`
2//! big-endian length prefix + JSON body (the same uniform framing dig-nat uses for its control
3//! messages).
4//!
5//! ## The four methods (RLY-style, aligned to the L7 peer-network wire)
6//!
7//! | Method | Request | Response | Purpose |
8//! |---|---|---|---|
9//! | `find_node` | `{ type:"find_node", target:<64hex key> }` | `{ type:"nodes", nodes:[Contact] }` | the `k` peers this node knows closest to `target` |
10//! | `find_providers` | `{ type:"find_providers", content_key:<64hex> }` | `{ type:"providers", providers:[ProviderRecord], closer:[Contact] }` | providers held locally + `k` closer peers if none |
11//! | `add_provider` | `{ type:"add_provider", record:ProviderRecord }` | `{ type:"add_provider_ok" }` | store the record (announce to the `k` closest) |
12//! | `ping` | `{ type:"ping", nonce:uint }` | `{ type:"pong", nonce:uint }` | liveness (echoes the nonce) |
13//!
14//! A [`Contact`] on the wire is `{ peer_id:<64hex>, addresses:[{host,port,
15//! kind}] }` — the same address shape as the L7 `dig.getPeers` peers and a [`ProviderRecord`]'s
16//! addresses, so a returned contact drops straight into a `PeerTarget` for [`dig_nat::connect`].
17//!
18//! Framing (`encode` / `decode`): a `u32` big-endian body length, then the JSON body, bounded by
19//! [`MAX_FRAMED_BODY`] to guard against a malicious length prefix. This is byte-identical to the
20//! dig-nat control framing so a node speaks one framing across the peer network.
21
22use std::io;
23
24use dig_nat::SafeText;
25use serde::{Deserialize, Serialize};
26use tokio::io::{AsyncRead, AsyncReadExt};
27
28use crate::record::ProviderRecord;
29use crate::routing::Contact;
30
31/// A DHT RPC **request** — one of the four methods, discriminated by `type`.
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(tag = "type", rename_all = "snake_case")]
34pub enum DhtRequest {
35    /// Ask for the `k` peers the responder knows closest to `target` (an XOR-metric target key).
36    FindNode {
37        /// The 64-hex target [`Key`](crate::Key) to find close peers to.
38        target: String,
39    },
40    /// Ask for providers of `content_key`; if the responder holds none, it returns the `k` peers it
41    /// knows closest to the key so the lookup can walk closer.
42    FindProviders {
43        /// The 64-hex content [`Key`](crate::Key) to find providers for.
44        content_key: String,
45    },
46    /// Announce that the record's `provider_peer_id` holds the record's content. The responder
47    /// stores the record (it is, by construction of the announce, one of the `k` closest to the key).
48    AddProvider {
49        /// The provider record to store.
50        record: ProviderRecord,
51    },
52    /// Liveness check — the responder echoes `nonce` in its `pong`.
53    Ping {
54        /// An opaque nonce the responder echoes back, so a caller can match pong to ping.
55        nonce: u64,
56    },
57}
58
59/// A DHT RPC **response**, discriminated by `type`. Each variant is the reply to the correspondingly
60/// named [`DhtRequest`].
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
62#[serde(tag = "type", rename_all = "snake_case")]
63pub enum DhtResponse {
64    /// Reply to [`DhtRequest::FindNode`]: the `k` closest contacts the responder knows.
65    Nodes {
66        /// Up to `k` contacts, closest-first to the requested target.
67        nodes: Vec<Contact>,
68    },
69    /// Reply to [`DhtRequest::FindProviders`]: any providers the responder holds for the key, plus
70    /// (always) the `closer` contacts so a lookup can continue toward the key even when providers
71    /// are already found (more may live nearer the key).
72    Providers {
73        /// Provider records the responder holds for the content key (may be empty).
74        providers: Vec<ProviderRecord>,
75        /// The `k` closest contacts the responder knows — used to walk the lookup closer.
76        closer: Vec<Contact>,
77    },
78    /// Reply to [`DhtRequest::AddProvider`]: the record was accepted + stored.
79    AddProviderOk,
80    /// Reply to [`DhtRequest::Ping`]: the echoed nonce (proves liveness + round-trip).
81    Pong {
82        /// The nonce from the ping.
83        nonce: u64,
84    },
85    /// A responder-side error (bad request, over capacity). Advisory — a lookup treats it like an
86    /// unreachable peer and moves on.
87    Error {
88        /// A stable error code.
89        code: u32,
90        /// A human-readable message.
91        message: String,
92    },
93}
94
95/// Maximum length-prefixed body — guards against a malicious length prefix forcing a huge
96/// allocation. Provider lists at `k = 20` with a handful of addresses each are well under this.
97pub const MAX_FRAMED_BODY: usize = 256 * 1024;
98
99impl DhtRequest {
100    /// Serialize as a `u32` big-endian length prefix + JSON body.
101    pub fn encode(&self) -> Vec<u8> {
102        encode_framed(self)
103    }
104    /// Read + decode a request from `r` (the serving side).
105    pub async fn decode<R: AsyncRead + Unpin>(r: &mut R) -> io::Result<Self> {
106        decode_framed(r).await
107    }
108}
109
110impl DhtResponse {
111    /// Serialize as a `u32` big-endian length prefix + JSON body.
112    pub fn encode(&self) -> Vec<u8> {
113        encode_framed(self)
114    }
115    /// Read + decode a response from `r` (the requesting side).
116    pub async fn decode<R: AsyncRead + Unpin>(r: &mut R) -> io::Result<Self> {
117        decode_framed(r).await
118    }
119}
120
121/// Serialize `value` as a `u32` big-endian length prefix + JSON body — the uniform DHT-RPC framing.
122fn encode_framed<T: Serialize>(value: &T) -> Vec<u8> {
123    let body = serde_json::to_vec(value).expect("dht message serializes");
124    let mut out = Vec::with_capacity(4 + body.len());
125    out.extend_from_slice(&(body.len() as u32).to_be_bytes());
126    out.extend_from_slice(&body);
127    out
128}
129
130/// Read + decode a length-prefixed JSON DHT message from `r`, bounded by [`MAX_FRAMED_BODY`].
131async fn decode_framed<T: for<'de> Deserialize<'de>, R: AsyncRead + Unpin>(
132    r: &mut R,
133) -> io::Result<T> {
134    let mut len_buf = [0u8; 4];
135    r.read_exact(&mut len_buf).await?;
136    let len = u32::from_be_bytes(len_buf) as usize;
137    if len > MAX_FRAMED_BODY {
138        return Err(io::Error::new(
139            io::ErrorKind::InvalidData,
140            "dht message too large",
141        ));
142    }
143    let mut body = vec![0u8; len];
144    r.read_exact(&mut body).await?;
145    serde_json::from_slice(&body).map_err(malformed_message)
146}
147
148/// Turn a `serde_json` decode failure on a PEER-SUPPLIED DHT message into an `io::Error` that says
149/// what went wrong without repeating what the peer sent (#1674).
150///
151/// [`DhtRequest`] and [`DhtResponse`] are internally tagged, and serde renders an unrecognised tag
152/// through plain `Display` — unquoted, unescaped. So `io::Error::new(InvalidData, e)` handed a
153/// stranger a direct write into the receiver's log: one frame, one forged line. The diagnosis is
154/// rebuilt from the error's category and position instead.
155fn malformed_message(error: serde_json::Error) -> io::Error {
156    io::Error::new(
157        io::ErrorKind::InvalidData,
158        format!(
159            "malformed dht message: {}",
160            SafeText::describing_json_error(&error)
161        ),
162    )
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168    use crate::record::{AddressKind, CandidateAddr};
169    use std::io::Cursor;
170
171    #[tokio::test]
172    async fn find_node_round_trips_framed() {
173        let req = DhtRequest::FindNode {
174            target: "ab".repeat(32),
175        };
176        let bytes = req.encode();
177        let mut cur = Cursor::new(bytes);
178        let back = DhtRequest::decode(&mut cur).await.unwrap();
179        assert_eq!(req, back);
180    }
181
182    #[tokio::test]
183    async fn providers_response_round_trips() {
184        let resp = DhtResponse::Providers {
185            providers: vec![ProviderRecord {
186                content_key: "cd".repeat(32),
187                provider_peer_id: "ef".repeat(32),
188                addresses: vec![CandidateAddr::direct("203.0.113.7", 9444)],
189                expires_at: 1_719_763_200,
190            }],
191            closer: vec![Contact {
192                peer_id: "12".repeat(32),
193                addresses: vec![CandidateAddr {
194                    host: "h".into(),
195                    port: 1,
196                    kind: AddressKind::Mapped,
197                }],
198            }],
199        };
200        let mut cur = Cursor::new(resp.encode());
201        let back = DhtResponse::decode(&mut cur).await.unwrap();
202        assert_eq!(resp, back);
203    }
204
205    #[test]
206    fn request_type_tags_are_snake_case() {
207        let s = serde_json::to_string(&DhtRequest::Ping { nonce: 7 }).unwrap();
208        assert!(s.contains("\"type\":\"ping\""));
209        assert!(s.contains("\"nonce\":7"));
210        let fp = serde_json::to_string(&DhtRequest::FindProviders {
211            content_key: "00".repeat(32),
212        })
213        .unwrap();
214        assert!(fp.contains("\"type\":\"find_providers\""));
215    }
216
217    #[test]
218    fn response_type_tags_are_snake_case() {
219        assert!(serde_json::to_string(&DhtResponse::AddProviderOk)
220            .unwrap()
221            .contains("\"type\":\"add_provider_ok\""));
222        assert!(serde_json::to_string(&DhtResponse::Pong { nonce: 9 })
223            .unwrap()
224            .contains("\"type\":\"pong\""));
225    }
226
227    #[tokio::test]
228    async fn oversize_length_prefix_is_rejected() {
229        // A frame claiming a body larger than MAX_FRAMED_BODY must error, not allocate.
230        let mut buf = ((MAX_FRAMED_BODY + 1) as u32).to_be_bytes().to_vec();
231        buf.extend_from_slice(b"{}");
232        let mut cur = Cursor::new(buf);
233        let err = DhtRequest::decode(&mut cur).await.unwrap_err();
234        assert_eq!(err.kind(), io::ErrorKind::InvalidData);
235    }
236
237    #[tokio::test]
238    async fn truncated_frame_errors() {
239        // A length prefix promising 100 bytes but only 2 present → error (not a hang / partial).
240        let mut buf = 100u32.to_be_bytes().to_vec();
241        buf.extend_from_slice(b"{}");
242        let mut cur = Cursor::new(buf);
243        assert!(DhtRequest::decode(&mut cur).await.is_err());
244    }
245}