Skip to main content

sysknife_daemon/transport/
framing.rs

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