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}