Skip to main content

gwk_kernel/wire/
frame.rs

1//! The v1 frame codec: `[u32 big-endian body_length][u8 frame_kind][body]`.
2//!
3//! `body_length` includes the kind byte and excludes the four-byte prefix, so
4//! the reader knows how much it is about to allocate before it allocates it.
5//! Every bound is checked against the announced length, never against what
6//! arrived — a peer that claims 4 GiB is refused after four bytes, not after
7//! four gigabytes.
8//!
9//! The budget is byte-accounted per CONNECTION and per WINDOW, not per frame. A
10//! peer that stays inside the frame cap forever cannot outrun its allowance,
11//! which is the bound that actually protects a long-lived connection — and
12//! because the allowance refills, a long-lived connection is possible at all.
13
14use std::time::Duration;
15
16use gwk_domain::protocol::{
17    CONNECTION_BUDGET_WINDOW_SECS, FRAME_BODY_MAX_BYTES, FRAME_BODY_MIN_BYTES,
18    FRAME_KIND_RESERVED_STREAM, FRAME_LENGTH_PREFIX_BYTES, FrameKind, KernelErrorCode,
19};
20use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
21use tokio::time::Instant;
22
23use super::WireError;
24
25/// One connection's send and receive allowance, refilling each window.
26///
27/// A RATE, not a lifetime total. The thing worth bounding is a peer flooding
28/// the kernel — how fast bytes arrive, and how fast the kernel is asked to
29/// produce them. A running total bounds that, but it bounds every legitimate
30/// use with it: a subscription is meant to run for days and a blob read moves
31/// as many bytes as the blob holds, so under a total both die at a threshold
32/// that says nothing about whether they misbehaved.
33///
34/// Exceeding the allowance therefore WAITS rather than failing. "Too fast" is
35/// answered by going slower; a connection is only killed when it has done
36/// something a slower version of would still be wrong. The one exception is a
37/// frame larger than a whole window's allowance, which no amount of waiting
38/// would ever admit — that is a misconfiguration, and it fails loudly instead
39/// of hanging forever.
40///
41/// Both directions are charged the FULL frame — prefix, kind byte, and body —
42/// because that is what the connection actually costs to move. Charging only
43/// the body would let a peer spend the allowance on a million one-byte frames
44/// and be told it had used a megabyte.
45///
46/// ponytail: a fixed window, so a peer can spend a full allowance at the end of
47/// one and again at the start of the next — a 2x burst across the boundary. For
48/// a flood guard that is immaterial; a token bucket if smoothing ever matters.
49#[derive(Debug)]
50pub struct Budget {
51    ingress_per_window: usize,
52    egress_per_window: usize,
53    window: Duration,
54    ingress_spent: usize,
55    egress_spent: usize,
56    window_started: Instant,
57}
58
59impl Budget {
60    pub fn new(ingress: usize, egress: usize) -> Self {
61        Self::with_window(
62            ingress,
63            egress,
64            Duration::from_secs(CONNECTION_BUDGET_WINDOW_SECS),
65        )
66    }
67
68    /// The same allowance over a caller-chosen window. Tests use a short one so
69    /// a refill is observable; nothing in the daemon does.
70    pub fn with_window(ingress: usize, egress: usize, window: Duration) -> Self {
71        Self {
72            ingress_per_window: ingress,
73            egress_per_window: egress,
74            window,
75            ingress_spent: 0,
76            egress_spent: 0,
77            window_started: Instant::now(),
78        }
79    }
80
81    async fn spend_ingress(&mut self, bytes: usize) -> Result<(), WireError> {
82        self.spend(bytes, Direction::Ingress).await
83    }
84
85    async fn spend_egress(&mut self, bytes: usize) -> Result<(), WireError> {
86        self.spend(bytes, Direction::Egress).await
87    }
88
89    async fn spend(&mut self, bytes: usize, direction: Direction) -> Result<(), WireError> {
90        let per_window = match direction {
91            Direction::Ingress => self.ingress_per_window,
92            Direction::Egress => self.egress_per_window,
93        };
94        // Checked before any waiting: this one can never be satisfied, and
95        // sleeping on it would turn a bad bound into a hung connection.
96        if bytes > per_window {
97            return Err(WireError::new(
98                KernelErrorCode::FrameSize,
99                format!(
100                    "a {direction} frame of {bytes} bytes exceeds the whole \
101                     {per_window}-byte window allowance"
102                ),
103            ));
104        }
105        loop {
106            let elapsed = self.window_started.elapsed();
107            if elapsed >= self.window {
108                self.ingress_spent = 0;
109                self.egress_spent = 0;
110                self.window_started = Instant::now();
111            }
112            let spent = match direction {
113                Direction::Ingress => &mut self.ingress_spent,
114                Direction::Egress => &mut self.egress_spent,
115            };
116            if *spent + bytes <= per_window {
117                *spent += bytes;
118                return Ok(());
119            }
120            // Wait out the remainder of this window, then reconsider. One sleep
121            // is always enough for a frame that fits a window at all, but the
122            // loop re-reads the clock rather than assuming that.
123            tokio::time::sleep(self.window - elapsed).await;
124        }
125    }
126}
127
128#[derive(Debug, Clone, Copy)]
129enum Direction {
130    Ingress,
131    Egress,
132}
133
134impl std::fmt::Display for Direction {
135    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
136        f.write_str(match self {
137            Self::Ingress => "received",
138            Self::Egress => "sent",
139        })
140    }
141}
142
143/// One decoded frame. The kind is already known-good; the body is raw bytes
144/// that nothing has parsed yet.
145#[derive(Debug, Clone, PartialEq, Eq)]
146pub struct Frame {
147    pub kind: FrameKind,
148    pub body: Vec<u8>,
149}
150
151/// The peer closed cleanly between frames, which is not an error.
152#[derive(Debug)]
153pub enum Incoming {
154    Frame(Frame),
155    Closed,
156}
157
158/// Read one frame, refusing an illegal length before allocating for it.
159///
160/// `max_body` is the caller's ceiling for THIS frame, which is how the hello's
161/// tighter 64 KiB cap is applied without a second codec: the handshake passes
162/// `HELLO_MAX_BYTES` and every frame after it passes `FRAME_BODY_MAX_BYTES`.
163/// A value above the protocol maximum is clamped rather than trusted.
164pub async fn read_frame<R>(
165    reader: &mut R,
166    max_body: u32,
167    budget: &mut Budget,
168) -> Result<Incoming, WireError>
169where
170    R: AsyncRead + Unpin,
171{
172    let ceiling = max_body.min(FRAME_BODY_MAX_BYTES);
173    let mut prefix = [0u8; FRAME_LENGTH_PREFIX_BYTES];
174    // The FIRST byte alone decides whether there is a frame at all. Reading the
175    // whole prefix in one call cannot tell "the peer hung up between frames"
176    // from "three bytes of a length arrived and then the stream ended" — both
177    // are `UnexpectedEof` — and calling the second one a clean close would let a
178    // truncated stream end a session silently.
179    match reader.read_exact(&mut prefix[..1]).await {
180        Ok(_) => {}
181        Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => return Ok(Incoming::Closed),
182        Err(e) => return Err(WireError::io("read frame length", e)),
183    }
184    reader
185        .read_exact(&mut prefix[1..])
186        .await
187        .map_err(|e| WireError::io("read frame length", e))?;
188    let announced = u32::from_be_bytes(prefix);
189
190    if announced < FRAME_BODY_MIN_BYTES {
191        // Zero is its own refusal rather than "a frame with no kind": a peer
192        // that can announce an empty frame can hold a reader in a loop that
193        // allocates nothing and never ends.
194        return Err(WireError::new(
195            KernelErrorCode::FrameSize,
196            format!(
197                "frame body_length {announced} is below the {FRAME_BODY_MIN_BYTES}-byte minimum"
198            ),
199        ));
200    }
201    if announced > ceiling {
202        return Err(WireError::new(
203            KernelErrorCode::FrameSize,
204            format!("frame body_length {announced} exceeds the {ceiling}-byte maximum"),
205        ));
206    }
207
208    let total = FRAME_LENGTH_PREFIX_BYTES + announced as usize;
209    budget.spend_ingress(total).await?;
210
211    let mut body = vec![0u8; announced as usize];
212    reader
213        .read_exact(&mut body)
214        .await
215        .map_err(|e| WireError::io("read frame body", e))?;
216
217    // The kind byte is the first body byte, so it is bounded by the same length
218    // that was just checked. Splitting it off here keeps every caller from
219    // re-deriving that the body starts at offset one.
220    let kind_byte = body.remove(0);
221    let kind = FrameKind::from_u8(kind_byte).ok_or_else(|| {
222        let detail = if kind_byte == FRAME_KIND_RESERVED_STREAM {
223            " (reserved for the terminal engine and not accepted in v1)"
224        } else {
225            ""
226        };
227        WireError::new(
228            KernelErrorCode::Handshake,
229            format!("unknown frame kind 0x{kind_byte:02x}{detail}"),
230        )
231    })?;
232    Ok(Incoming::Frame(Frame { kind, body }))
233}
234
235/// Write one frame, charging the connection's egress budget for all of it.
236pub async fn write_frame<W>(
237    writer: &mut W,
238    kind: FrameKind,
239    body: &[u8],
240    budget: &mut Budget,
241) -> Result<(), WireError>
242where
243    W: AsyncWrite + Unpin,
244{
245    // The kind byte counts toward body_length, so the check is on body + 1 and
246    // an off-by-one here would ship a frame no conforming reader can accept.
247    let announced = u32::try_from(body.len() + 1).map_err(|_| {
248        WireError::new(
249            KernelErrorCode::FrameSize,
250            format!("frame body {} bytes does not fit a u32 length", body.len()),
251        )
252    })?;
253    if announced > FRAME_BODY_MAX_BYTES {
254        return Err(WireError::new(
255            KernelErrorCode::FrameSize,
256            format!(
257                "frame body_length {announced} exceeds the {FRAME_BODY_MAX_BYTES}-byte maximum"
258            ),
259        ));
260    }
261    budget
262        .spend_egress(FRAME_LENGTH_PREFIX_BYTES + announced as usize)
263        .await?;
264
265    // One buffer, one write: two writes would let a reader on the other side
266    // observe a length with no body behind it if this task is cancelled between
267    // them.
268    let mut out = Vec::with_capacity(FRAME_LENGTH_PREFIX_BYTES + announced as usize);
269    out.extend_from_slice(&announced.to_be_bytes());
270    out.push(kind.as_u8());
271    out.extend_from_slice(body);
272    writer
273        .write_all(&out)
274        .await
275        .map_err(|e| WireError::io("write frame", e))?;
276    writer
277        .flush()
278        .await
279        .map_err(|e| WireError::io("flush frame", e))
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285
286    fn budget() -> Budget {
287        Budget::new(1 << 20, 1 << 20)
288    }
289
290    async fn read_bytes(raw: &[u8], max_body: u32) -> Result<Incoming, WireError> {
291        read_frame(
292            &mut std::io::Cursor::new(raw.to_vec()),
293            max_body,
294            &mut budget(),
295        )
296        .await
297    }
298
299    #[tokio::test]
300    async fn a_frame_survives_its_own_round_trip() {
301        let mut wire = Vec::new();
302        let mut out = budget();
303        write_frame(
304            &mut wire,
305            FrameKind::Json,
306            b"{\"type\":\"health\"}",
307            &mut out,
308        )
309        .await
310        .expect("write");
311        // body_length counts the kind byte: 17 bytes of JSON plus one.
312        assert_eq!(&wire[..4], &18u32.to_be_bytes());
313        assert_eq!(wire[4], FrameKind::Json.as_u8());
314
315        match read_bytes(&wire, FRAME_BODY_MAX_BYTES).await.expect("read") {
316            Incoming::Frame(frame) => {
317                assert_eq!(frame.kind, FrameKind::Json);
318                assert_eq!(frame.body, b"{\"type\":\"health\"}");
319            }
320            Incoming::Closed => panic!("closed on a whole frame"),
321        }
322    }
323
324    /// One runtime per case. The codec is async and `proptest!` bodies are not,
325    /// and a current-thread runtime costs less than the case it runs.
326    fn block_on<F: Future>(future: F) -> F::Output {
327        tokio::runtime::Builder::new_current_thread()
328            .build()
329            .expect("a current-thread runtime")
330            .block_on(future)
331    }
332
333    proptest::proptest! {
334        /// The example above round-trips ONE body. The codec's promise is about
335        /// every body, and the interesting ones are the shapes nobody thinks to
336        /// write down: empty, a lone NUL, invalid UTF-8, a length that happens
337        /// to look like another frame's prefix.
338        ///
339        /// Bounded at 8 KiB rather than the 4 MiB ceiling because the ceiling is
340        /// a boundary — proved exactly, twice, by the cases around this one —
341        /// while this is about the space in between.
342        #[test]
343        fn any_body_survives_the_round_trip(body in proptest::collection::vec(proptest::num::u8::ANY, 0..8192)) {
344            let read = block_on(async {
345                let mut wire = Vec::new();
346                let mut out = budget();
347                write_frame(&mut wire, FrameKind::Json, &body, &mut out).await.expect("write");
348                // The length prefix counts the kind byte, so it is never zero
349                // even for an empty body — which is what keeps a zero-length
350                // announcement available as an illegal value.
351                assert_eq!(&wire[..4], &(body.len() as u32 + 1).to_be_bytes());
352                read_bytes(&wire, FRAME_BODY_MAX_BYTES).await.expect("read")
353            });
354            match read {
355                Incoming::Frame(frame) => {
356                    proptest::prop_assert_eq!(frame.kind, FrameKind::Json);
357                    proptest::prop_assert_eq!(frame.body, body);
358                }
359                Incoming::Closed => proptest::prop_assert!(false, "closed on a whole frame"),
360            }
361        }
362
363        /// Every byte that is not the one legal kind is refused, including the
364        /// reserved engine stream kind. Enumerated rather than reasoned about: a
365        /// `match` arm that accidentally admits one byte is exactly the kind of
366        /// mistake that reads correctly.
367        #[test]
368        fn no_other_kind_byte_is_admitted(kind in proptest::num::u8::ANY) {
369            proptest::prop_assume!(kind != FrameKind::Json.as_u8());
370            let mut wire = 2u32.to_be_bytes().to_vec();
371            wire.push(kind);
372            wire.push(b'x');
373            let error = block_on(read_bytes(&wire, FRAME_BODY_MAX_BYTES))
374                .expect_err("an unknown kind byte was admitted");
375            proptest::prop_assert_eq!(error.code, KernelErrorCode::Handshake);
376        }
377
378        /// Arbitrary bytes from the socket must not panic the codec. This is the
379        /// trust boundary — the first thing an unauthenticated peer reaches — and
380        /// a panic in it is a denial of service that no amount of validation
381        /// further in can prevent.
382        #[test]
383        fn arbitrary_bytes_are_answered_and_never_panic(raw in proptest::collection::vec(proptest::num::u8::ANY, 0..512)) {
384            // Any of the three outcomes is correct; there is no fourth, and
385            // reaching one at all is the property.
386            let _ = block_on(read_bytes(&raw, FRAME_BODY_MAX_BYTES));
387        }
388    }
389
390    #[tokio::test]
391    async fn an_illegal_length_is_refused_from_the_prefix_alone() {
392        // Four bytes in, nothing allocated: the whole point of putting the
393        // length first. Both cases carry ONLY a prefix — if either were being
394        // decided after reading a body, these would hang instead of refusing.
395        for (announced, expected) in [(0u32, "below"), (FRAME_BODY_MAX_BYTES + 1, "exceeds")] {
396            let error = read_bytes(&announced.to_be_bytes(), FRAME_BODY_MAX_BYTES)
397                .await
398                .expect_err("illegal length accepted");
399            assert_eq!(error.code, KernelErrorCode::FrameSize);
400            assert!(error.message.contains(expected), "{error}");
401        }
402    }
403
404    #[tokio::test]
405    async fn the_hello_cap_is_the_same_codec_with_a_lower_ceiling() {
406        let announced = gwk_domain::protocol::HELLO_MAX_BYTES + 1;
407        let error = read_bytes(
408            &announced.to_be_bytes(),
409            gwk_domain::protocol::HELLO_MAX_BYTES,
410        )
411        .await
412        .expect_err("oversized hello accepted");
413        assert_eq!(error.code, KernelErrorCode::FrameSize);
414        // The same bytes are fine once the handshake is over.
415        let mut whole = announced.to_be_bytes().to_vec();
416        whole.push(FrameKind::Json.as_u8());
417        whole.extend(std::iter::repeat_n(b' ', announced as usize - 1));
418        assert!(matches!(
419            read_bytes(&whole, FRAME_BODY_MAX_BYTES)
420                .await
421                .expect("read"),
422            Incoming::Frame(_)
423        ));
424    }
425
426    #[tokio::test]
427    async fn the_reserved_engine_kind_is_refused_by_name() {
428        let mut raw = 1u32.to_be_bytes().to_vec();
429        raw.push(FRAME_KIND_RESERVED_STREAM);
430        let error = read_bytes(&raw, FRAME_BODY_MAX_BYTES)
431            .await
432            .expect_err("reserved kind accepted");
433        assert_eq!(error.code, KernelErrorCode::Handshake);
434        assert!(error.message.contains("terminal engine"), "{error}");
435    }
436
437    #[tokio::test]
438    async fn a_clean_hangup_between_frames_is_not_an_error() {
439        assert!(matches!(
440            read_bytes(&[], FRAME_BODY_MAX_BYTES).await.expect("eof"),
441            Incoming::Closed
442        ));
443        // A PARTIAL prefix is a different thing: bytes were lost.
444        assert!(read_bytes(&[0, 0], FRAME_BODY_MAX_BYTES).await.is_err());
445    }
446
447    #[tokio::test(start_paused = true)]
448    async fn a_peer_that_outruns_its_allowance_waits_instead_of_dying() {
449        // Twelve bytes a window against a frame that costs seven: the first
450        // fits and the second cannot, until the allowance refills.
451        let mut small = Budget::new(12, 12);
452        let mut wire = Vec::new();
453        let mut writing = Budget::new(1 << 20, 1 << 20);
454        write_frame(&mut wire, FrameKind::Json, b"ab", &mut writing)
455            .await
456            .expect("write");
457        let mut stream = std::io::Cursor::new([wire.clone(), wire].concat());
458
459        let started = Instant::now();
460        read_frame(&mut stream, FRAME_BODY_MAX_BYTES, &mut small)
461            .await
462            .expect("first frame");
463        // 4 prefix + 1 kind + 2 body = 7 charged, not 2, which is why twelve
464        // does not cover two of them.
465        assert!(
466            started.elapsed() < Duration::from_millis(1),
467            "the first frame should not have waited"
468        );
469
470        read_frame(&mut stream, FRAME_BODY_MAX_BYTES, &mut small)
471            .await
472            .expect("the second frame arrives after the refill");
473        // It ARRIVED. The connection was throttled, not killed, and that is the
474        // whole difference between a rate and a lifetime cap — under a total
475        // this peer would be finished, having done nothing but read two frames.
476        assert!(
477            started.elapsed() >= Duration::from_secs(CONNECTION_BUDGET_WINDOW_SECS),
478            "the second frame was not made to wait for its window"
479        );
480    }
481
482    #[tokio::test]
483    async fn a_frame_larger_than_a_whole_window_fails_rather_than_hanging() {
484        let mut sink = Vec::new();
485        // 4 prefix + 1 kind + 4 body = 9 bytes against a 6-byte window. No
486        // amount of waiting ever admits it, so waiting would be a hang dressed
487        // as a limit.
488        let mut small = Budget::new(0, 6);
489        let error = write_frame(&mut sink, FrameKind::Json, b"abcd", &mut small)
490            .await
491            .expect_err("an unaffordable frame was not refused");
492        assert_eq!(error.code, KernelErrorCode::FrameSize);
493        assert!(error.message.contains("sent"), "{error}");
494        // Nothing reached the writer: a frame that cannot be afforded is not
495        // half-sent.
496        assert!(sink.is_empty());
497    }
498}