dig_peer/rpc.rs
1//! The peer-stream RPC framing — how a typed JSON-RPC call rides one mux stream.
2//!
3//! `dig-rpc` is an HTTP JSON-RPC *server*; the peer surface instead carries JSON-RPC over `dig-nat`'s
4//! multiplexed mTLS streams. dig-peer defines that on-stream framing here and is the CLIENT that
5//! speaks it. Each call opens ONE fresh logical stream, writes a single length-prefixed request body,
6//! reads a single length-prefixed response body, and lets the stream close — a clean request/response
7//! per stream, with concurrency provided by the mux (open many streams).
8//!
9//! ## Framing (normative)
10//!
11//! A body is a `u32` big-endian length prefix followed by that many bytes — the SAME uniform framing
12//! `dig-nat`'s control messages use, so the two never disagree. For an **unsealed** (public-read)
13//! call the body is the JSON of a `JsonRpcRequest`/`JsonRpcResponse`. For a **directed** (sealed)
14//! call the body is the byte-serialized sealed [`dig_message`] envelope wrapping that JSON (§5.4).
15//! The [`MAX_BODY`] bound guards against a malicious length prefix forcing a huge allocation.
16
17use serde::de::DeserializeOwned;
18use serde::Serialize;
19use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
20
21use crate::error::{DigPeerError, Result};
22use dig_nat::SafeText;
23
24/// Maximum length-prefixed body dig-peer will read — guards against a hostile length prefix. Matches
25/// dig-nat's control-frame bound (64 KiB) for small control/RPC messages.
26pub const MAX_BODY: usize = 64 * 1024;
27
28/// Write a length-prefixed body (`u32` big-endian length + bytes) to `w`.
29pub async fn write_framed<W: AsyncWrite + Unpin>(w: &mut W, body: &[u8]) -> Result<()> {
30 if body.len() > MAX_BODY {
31 // Our own sentence with a LENGTH interpolated. `from_untrusted` is still the honest door —
32 // the length is ours here, but a decimal integer cannot carry a control character either
33 // way, so naming the provenance costs nothing and keeps one rule for the whole file.
34 return Err(DigPeerError::Codec(SafeText::from_untrusted(format!(
35 "outbound body {} exceeds the {MAX_BODY}-byte bound",
36 body.len()
37 ))));
38 }
39 w.write_all(&(body.len() as u32).to_be_bytes()).await?;
40 w.write_all(body).await?;
41 w.flush().await?;
42 Ok(())
43}
44
45/// Read one length-prefixed body (`u32` big-endian length + bytes) from `r`, bounded by [`MAX_BODY`].
46pub async fn read_framed<R: AsyncRead + Unpin>(r: &mut R) -> Result<Vec<u8>> {
47 let mut len_buf = [0u8; 4];
48 r.read_exact(&mut len_buf).await?;
49 let len = u32::from_be_bytes(len_buf) as usize;
50 if len > MAX_BODY {
51 // `len` is PEER-CHOSEN — a declared length from the wire. It is an integer, so it cannot
52 // inject; it goes through the untrusted door anyway so the provenance is visible here.
53 return Err(DigPeerError::Codec(SafeText::from_untrusted(format!(
54 "inbound body length {len} exceeds the {MAX_BODY}-byte bound"
55 ))));
56 }
57 let mut body = vec![0u8; len];
58 r.read_exact(&mut body).await?;
59 Ok(body)
60}
61
62/// Serialize a value to a JSON body for the wire.
63pub fn to_json<T: Serialize>(value: &T) -> Result<Vec<u8>> {
64 serde_json::to_vec(value).map_err(|e| DigPeerError::codec_from_json(&e))
65}
66
67/// Deserialize a JSON body from the wire into a typed value.
68pub fn from_json<T: DeserializeOwned>(bytes: &[u8]) -> Result<T> {
69 serde_json::from_slice(bytes).map_err(|e| DigPeerError::codec_from_json(&e))
70}
71
72#[cfg(test)]
73mod tests {
74 use super::*;
75
76 /// **Proves:** a body written by [`write_framed`] is read back byte-identically by
77 /// [`read_framed`] — the length-prefixed framing is self-consistent.
78 #[tokio::test]
79 async fn framed_body_round_trips() {
80 let body = b"{\"jsonrpc\":\"2.0\"}".to_vec();
81 let mut buf = Vec::new();
82 write_framed(&mut buf, &body).await.expect("write");
83 let mut cursor = std::io::Cursor::new(buf);
84 let read = read_framed(&mut cursor).await.expect("read");
85 assert_eq!(read, body);
86 }
87
88 /// **Proves:** an empty body frames + unframes cleanly (a zero-length prefix is valid).
89 #[tokio::test]
90 async fn empty_body_round_trips() {
91 let mut buf = Vec::new();
92 write_framed(&mut buf, &[]).await.expect("write");
93 let mut cursor = std::io::Cursor::new(buf);
94 assert!(read_framed(&mut cursor).await.expect("read").is_empty());
95 }
96
97 /// **Proves:** writing a body over the [`MAX_BODY`] bound is refused, guarding against a caller
98 /// serializing an oversized payload.
99 #[tokio::test]
100 async fn oversized_write_is_refused() {
101 let big = vec![0u8; MAX_BODY + 1];
102 let mut buf = Vec::new();
103 let result = write_framed(&mut buf, &big).await;
104 assert!(matches!(result, Err(DigPeerError::Codec(_))));
105 }
106
107 /// **Proves:** a length prefix over [`MAX_BODY`] is rejected before allocating, guarding against a
108 /// hostile prefix forcing a huge allocation.
109 #[tokio::test]
110 async fn oversized_length_prefix_is_rejected() {
111 let mut framed = ((MAX_BODY + 1) as u32).to_be_bytes().to_vec();
112 framed.extend_from_slice(&[0u8; 8]);
113 let mut cursor = std::io::Cursor::new(framed);
114 assert!(matches!(
115 read_framed(&mut cursor).await,
116 Err(DigPeerError::Codec(_))
117 ));
118 }
119
120 /// **Proves:** [`to_json`]/[`from_json`] round-trip a value; a malformed body is a `Codec` error.
121 #[test]
122 fn json_round_trips_and_rejects_garbage() {
123 let value = serde_json::json!({"a": 1});
124 let bytes = to_json(&value).expect("to_json");
125 let back: serde_json::Value = from_json(&bytes).expect("from_json");
126 assert_eq!(value, back);
127 assert!(matches!(
128 from_json::<serde_json::Value>(b"not json"),
129 Err(DigPeerError::Codec(_))
130 ));
131 }
132}