Skip to main content

sysknife_daemon/transport/
framing.rs

1use std::io;
2use tokio::io::{AsyncReadExt, AsyncWriteExt};
3
4/// Maximum body size accepted by `recv`. Connections sending a larger
5/// length header are terminated immediately.
6pub const MAX_MESSAGE_BYTES: usize = 4 * 1024 * 1024;
7
8#[derive(Debug, thiserror::Error)]
9pub enum FramingError {
10    #[error("message too large: {0} bytes (max {MAX_MESSAGE_BYTES})")]
11    MessageTooLarge(usize),
12    #[error("io error: {0}")]
13    Io(#[from] io::Error),
14}
15
16/// Wraps an async stream with 4-byte little-endian length-prefix framing.
17///
18/// Each message is sent as `[len: u32 LE][body: len bytes]`. The maximum
19/// body length is [`MAX_MESSAGE_BYTES`]; larger messages are rejected at
20/// both the send and receive side.
21pub struct FramedStream<S> {
22    inner: S,
23}
24
25impl<S> FramedStream<S> {
26    pub fn new(inner: S) -> Self {
27        Self { inner }
28    }
29
30    pub fn into_inner(self) -> S {
31        self.inner
32    }
33}
34
35impl<S: AsyncReadExt + AsyncWriteExt + Unpin> FramedStream<S> {
36    pub async fn send(&mut self, data: &[u8]) -> Result<(), FramingError> {
37        if data.len() > MAX_MESSAGE_BYTES {
38            return Err(FramingError::MessageTooLarge(data.len()));
39        }
40        self.inner
41            .write_all(&(data.len() as u32).to_le_bytes())
42            .await?;
43        self.inner.write_all(data).await?;
44        Ok(())
45    }
46
47    pub async fn recv(&mut self) -> Result<Vec<u8>, FramingError> {
48        let mut header = [0u8; 4];
49        self.inner.read_exact(&mut header).await?;
50        let len = u32::from_le_bytes(header) as usize;
51        if len > MAX_MESSAGE_BYTES {
52            return Err(FramingError::MessageTooLarge(len));
53        }
54        // Grow the buffer as bytes actually arrive rather than pre-allocating the
55        // full claimed length. A peer that announces a large body but withholds
56        // it (partial-frame DoS, #150) then pins only what it has actually sent,
57        // not the whole claim — so N stalled connections cannot hold
58        // N * MAX_MESSAGE_BYTES of memory while their pre-auth deadline runs.
59        // The initial reservation is bounded, and reads are chunked.
60        let mut body = Vec::with_capacity(len.min(INITIAL_BODY_CAPACITY));
61        let mut chunk = [0u8; READ_CHUNK_BYTES];
62        while body.len() < len {
63            let want = (len - body.len()).min(chunk.len());
64            let n = self.inner.read(&mut chunk[..want]).await?;
65            if n == 0 {
66                return Err(FramingError::Io(io::Error::new(
67                    io::ErrorKind::UnexpectedEof,
68                    "connection closed before the full frame body arrived",
69                )));
70            }
71            body.extend_from_slice(&chunk[..n]);
72        }
73        Ok(body)
74    }
75}
76
77/// Upper bound on the buffer reserved up front for a frame body, regardless of
78/// the claimed length. A larger body grows the buffer incrementally as data
79/// arrives, so a withheld body never costs more than this.
80const INITIAL_BODY_CAPACITY: usize = 64 * 1024;
81
82/// Size of the stack read buffer used to stream a frame body.
83const READ_CHUNK_BYTES: usize = 64 * 1024;
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88    use std::pin::Pin;
89    use std::sync::atomic::{AtomicUsize, Ordering};
90    use std::sync::Arc;
91    use std::task::{Context, Poll};
92    use tokio::io::{duplex, AsyncWriteExt, ReadBuf};
93
94    /// Wraps a reader and records the largest buffer size `recv` ever asks it to
95    /// fill in a single `poll_read`. This is what distinguishes the streaming
96    /// reader from the old `vec![0u8; len]; read_exact(&mut body)`: the old code
97    /// handed the whole claimed body to one `poll_read` (up to 4 MiB), the new
98    /// code never asks for more than `READ_CHUNK_BYTES` at a time.
99    struct RecordingReader<R> {
100        inner: R,
101        max_requested: Arc<AtomicUsize>,
102    }
103    impl<R: tokio::io::AsyncRead + Unpin> tokio::io::AsyncRead for RecordingReader<R> {
104        fn poll_read(
105            mut self: Pin<&mut Self>,
106            cx: &mut Context<'_>,
107            buf: &mut ReadBuf<'_>,
108        ) -> Poll<io::Result<()>> {
109            self.max_requested
110                .fetch_max(buf.remaining(), Ordering::SeqCst);
111            Pin::new(&mut self.inner).poll_read(cx, buf)
112        }
113    }
114    // FramedStream's recv/send impl block requires AsyncWrite too; this half is
115    // only ever read in the test, so the writes just pass through.
116    impl<W: tokio::io::AsyncWrite + Unpin> tokio::io::AsyncWrite for RecordingReader<W> {
117        fn poll_write(
118            mut self: Pin<&mut Self>,
119            cx: &mut Context<'_>,
120            buf: &[u8],
121        ) -> Poll<io::Result<usize>> {
122            Pin::new(&mut self.inner).poll_write(cx, buf)
123        }
124        fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
125            Pin::new(&mut self.inner).poll_flush(cx)
126        }
127        fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
128            Pin::new(&mut self.inner).poll_shutdown(cx)
129        }
130    }
131
132    #[tokio::test]
133    async fn recv_never_requests_more_than_one_chunk_per_read() {
134        // A body several chunks long, fully delivered. The streaming reader must
135        // never ask the underlying stream to fill more than READ_CHUNK_BYTES at
136        // once — that bound is what stops a large claimed length from pinning
137        // memory. Mutation guard: the old vec![0u8; len]; read_exact asked for the
138        // whole body in one poll_read, so this fails against it and passes only
139        // for the chunked reader.
140        let payload = vec![0x5Au8; 5 * READ_CHUNK_BYTES + 7];
141        let (mut a, b) = duplex(MAX_MESSAGE_BYTES + 8);
142        let max_requested = Arc::new(AtomicUsize::new(0));
143        let mut recvr = FramedStream::new(RecordingReader {
144            inner: b,
145            max_requested: Arc::clone(&max_requested),
146        });
147        let p2 = payload.clone();
148        let writer = tokio::spawn(async move {
149            a.write_all(&(p2.len() as u32).to_le_bytes()).await.unwrap();
150            a.write_all(&p2).await.unwrap();
151        });
152        let got = recvr.recv().await.expect("recv should reassemble the body");
153        writer.await.unwrap();
154        assert_eq!(got, payload);
155        let peak = max_requested.load(Ordering::SeqCst);
156        assert!(
157            peak <= READ_CHUNK_BYTES,
158            "recv asked to fill {peak} bytes in one read; must be <= {READ_CHUNK_BYTES}"
159        );
160    }
161
162    #[tokio::test]
163    async fn round_trip_a_full_max_size_body() {
164        // The largest legitimate message: exactly MAX_MESSAGE_BYTES (64 full
165        // chunks). Exercises the whole chunk loop and guards the len-boundary
166        // arithmetic on a real transfer, not just a claim.
167        let data = vec![0x9Cu8; MAX_MESSAGE_BYTES];
168        assert_eq!(round_trip(&data).await, data);
169    }
170
171    #[tokio::test]
172    async fn round_trip_a_body_of_exactly_one_chunk() {
173        // len == READ_CHUNK_BYTES: the last (only) iteration fills a whole chunk,
174        // the natural place for a `<=` vs `<` slip in the loop to surface.
175        let data = vec![0x3Fu8; READ_CHUNK_BYTES];
176        assert_eq!(round_trip(&data).await, data);
177    }
178
179    /// Send `data` from one half of a duplex pair, receive from the other.
180    async fn round_trip(data: &[u8]) -> Vec<u8> {
181        let (a, b) = duplex(MAX_MESSAGE_BYTES + 8);
182        let mut sender = FramedStream::new(a);
183        let mut recvr = FramedStream::new(b);
184        sender.send(data).await.expect("send failed");
185        recvr.recv().await.expect("recv failed")
186    }
187
188    #[tokio::test]
189    async fn round_trip_empty_message() {
190        assert_eq!(round_trip(b"").await, b"");
191    }
192
193    #[tokio::test]
194    async fn round_trip_single_byte() {
195        assert_eq!(round_trip(b"x").await, b"x");
196    }
197
198    #[tokio::test]
199    async fn round_trip_4095_bytes() {
200        let data = vec![0xABu8; 4095];
201        assert_eq!(round_trip(&data).await, data);
202    }
203
204    #[tokio::test]
205    async fn round_trip_4096_bytes() {
206        let data = vec![0xCDu8; 4096];
207        assert_eq!(round_trip(&data).await, data);
208    }
209
210    #[tokio::test]
211    async fn round_trip_json_payload() {
212        let msg = br#"{"type":"preview","action_name":"GetSystemState"}"#;
213        assert_eq!(round_trip(msg).await, msg);
214    }
215
216    #[tokio::test]
217    async fn a_body_arriving_in_many_small_chunks_is_reassembled() {
218        // Payload larger than one read chunk, delivered through a small duplex
219        // buffer so recv must loop over several reads and stitch them together.
220        let payload = vec![0xABu8; 3 * READ_CHUNK_BYTES + 123];
221        let (mut a, b) = duplex(4096);
222        let mut recvr = FramedStream::new(b);
223        let p2 = payload.clone();
224        let writer = tokio::spawn(async move {
225            a.write_all(&(p2.len() as u32).to_le_bytes()).await.unwrap();
226            a.write_all(&p2).await.unwrap();
227        });
228        let got = recvr.recv().await.expect("recv should reassemble the body");
229        writer.await.unwrap();
230        assert_eq!(got, payload);
231    }
232
233    #[tokio::test]
234    async fn a_withheld_body_after_a_large_length_claim_errors_promptly() {
235        // Announce the maximum body size but send only a few bytes, then close.
236        // The reader must error out on EOF rather than hang waiting for the
237        // withheld body or return a short body as if complete. (The bounded-memory
238        // property is proven separately by recv_never_requests_more_than_one_chunk_per_read;
239        // this test only guards the promptness/EOF behavior.)
240        let (mut a, b) = duplex(1024);
241        let mut recvr = FramedStream::new(b);
242        let writer = tokio::spawn(async move {
243            a.write_all(&(MAX_MESSAGE_BYTES as u32).to_le_bytes())
244                .await
245                .unwrap();
246            a.write_all(b"partial").await.unwrap();
247            drop(a); // close -> EOF before the claimed body arrives
248        });
249        let err = recvr
250            .recv()
251            .await
252            .expect_err("a truncated body must error, not hang or succeed");
253        writer.await.unwrap();
254        match err {
255            FramingError::Io(e) => assert_eq!(e.kind(), io::ErrorKind::UnexpectedEof),
256            other => panic!("expected UnexpectedEof, got {other}"),
257        }
258    }
259
260    #[tokio::test]
261    async fn send_rejects_message_over_4mib() {
262        let (a, _b) = duplex(8);
263        let mut sender = FramedStream::new(a);
264        let oversized = vec![0u8; MAX_MESSAGE_BYTES + 1];
265        let err = sender.send(&oversized).await.unwrap_err();
266        assert!(
267            matches!(err, FramingError::MessageTooLarge(n) if n == MAX_MESSAGE_BYTES + 1),
268            "expected MessageTooLarge, got: {err}"
269        );
270    }
271
272    #[tokio::test]
273    async fn recv_rejects_header_claiming_over_4mib() {
274        let (a, b) = duplex(16);
275        let mut raw_sender = a;
276        let mut recvr = FramedStream::new(b);
277        // Write a header claiming MAX + 1 bytes
278        let oversized_len = (MAX_MESSAGE_BYTES + 1) as u32;
279        raw_sender
280            .write_all(&oversized_len.to_le_bytes())
281            .await
282            .unwrap();
283        let err = recvr.recv().await.unwrap_err();
284        assert!(
285            matches!(err, FramingError::MessageTooLarge(n) if n == MAX_MESSAGE_BYTES + 1),
286            "expected MessageTooLarge, got: {err}"
287        );
288    }
289
290    #[tokio::test]
291    async fn truncated_body_errors_instead_of_hanging() {
292        // A header promising N bytes followed by fewer than N, then EOF. The
293        // reader must fail, not block forever waiting for bytes that will
294        // never arrive — a peer that dies mid-frame otherwise pins a
295        // connection slot for the life of the daemon.
296        let (a, b) = duplex(MAX_MESSAGE_BYTES + 8);
297        let mut recvr = FramedStream::new(b);
298        let mut raw_sender = a;
299
300        raw_sender.write_all(&8u32.to_le_bytes()).await.unwrap();
301        raw_sender.write_all(b"abc").await.unwrap(); // 3 of the promised 8
302        drop(raw_sender); // EOF mid-body
303
304        let err = recvr.recv().await.unwrap_err();
305        assert!(
306            matches!(err, FramingError::Io(_)),
307            "a truncated body must surface as an I/O error, got: {err}"
308        );
309    }
310
311    #[tokio::test]
312    async fn truncated_header_errors_instead_of_hanging() {
313        // Same failure mode one step earlier: the 4-byte length prefix itself
314        // arrives incomplete.
315        let (a, b) = duplex(MAX_MESSAGE_BYTES + 8);
316        let mut recvr = FramedStream::new(b);
317        let mut raw_sender = a;
318
319        raw_sender.write_all(&[0x01, 0x02]).await.unwrap(); // 2 of 4
320        drop(raw_sender);
321
322        let err = recvr.recv().await.unwrap_err();
323        assert!(
324            matches!(err, FramingError::Io(_)),
325            "a truncated header must surface as an I/O error, got: {err}"
326        );
327    }
328
329    #[tokio::test]
330    async fn multiple_messages_on_same_stream() {
331        let (a, b) = duplex(MAX_MESSAGE_BYTES + 8);
332        let mut sender = FramedStream::new(a);
333        let mut recvr = FramedStream::new(b);
334        sender.send(b"first").await.unwrap();
335        sender.send(b"second").await.unwrap();
336        assert_eq!(recvr.recv().await.unwrap(), b"first");
337        assert_eq!(recvr.recv().await.unwrap(), b"second");
338    }
339}