diff --git a/crates/net/src/codec.rs b/crates/net/src/codec.rs index 9ead433..a715778 100644 --- a/crates/net/src/codec.rs +++ b/crates/net/src/codec.rs @@ -2,4 +2,258 @@ //! Length-prefixed `postcard` frame codec. //! -//! Encodes and decodes one logical protocol message per record on a QUIC stream, using a length prefix so a reader can recover record boundaries from a byte stream. Populated in a later concept; currently a placeholder. +//! Encodes and decodes one logical protocol message per record on a QUIC stream, using a length prefix so a reader can recover record boundaries from a byte stream. + +/// Errors produced by the framing codec and its stream I/O helpers. +#[derive(Debug, thiserror::Error)] +pub enum NetError { + /// A `postcard` serialization or deserialization operation failed. + #[error("postcard codec error: {0}")] + Postcard(#[from] postcard::Error), + /// An underlying byte-stream I/O operation failed. + #[error("i/o error: {0}")] + Io(#[from] std::io::Error), + /// A declared frame length exceeded the caller-supplied maximum, indicating a malicious or corrupt peer. + #[error("frame length {len} exceeds maximum {max}")] + FrameTooLarge { + /// The frame length declared by the length prefix, in bytes. + len: u64, + /// The maximum payload length accepted by the reader, in bytes. + max: usize, + }, + /// The stream ended before a complete frame (prefix or payload) had been read. + #[error("unexpected end of stream while reading a frame")] + UnexpectedEof, + /// A varint length prefix was malformed: either overlong or otherwise invalid. + #[error("malformed varint length prefix")] + MalformedVarint, + /// Writing bytes to a quinn send stream failed. + #[error("quinn write error: {0}")] + Write(#[from] quinn::WriteError), + /// Reading an exact number of bytes from a quinn recv stream failed. + #[error("quinn read error: {0}")] + Read(#[from] quinn::ReadExactError), +} + +/// The maximum payload length, in bytes, accepted on the control stream (64 KiB), matching the mod-payload cap. Higher-bandwidth tiers such as chunk streaming define their own caps. +pub const MAX_CONTROL_FRAME_LEN: usize = 64 * 1024; + +/// The maximum number of bytes an unsigned LEB128 varint may occupy for a `u64` value (`ceil(64 / 7)`). +const MAX_VARINT_LEN: usize = 10; + +/// Appends `value` to `buf` as an unsigned LEB128 varint. +/// +/// Each byte carries seven value bits in little-endian group order; the high bit (`0x80`) is a continuation flag set on every byte except the last. +fn write_varint(value: u64, buf: &mut Vec) { + let mut remaining = value; + loop { + // Extract the low seven bits of the remaining value. + let mut byte = (remaining & 0x7f) as u8; + remaining >>= 7; + if remaining != 0 { + // Further bytes follow, so mark the continuation bit. + byte |= 0x80; + } + buf.push(byte); + if remaining == 0 { + break; + } + } +} + +/// Reads an unsigned LEB128 varint from the front of `bytes`, returning the decoded value and the number of bytes consumed. +fn read_varint(bytes: &[u8]) -> Result<(u64, usize), NetError> { + let mut value: u64 = 0; + let mut shift: u32 = 0; + for (index, &byte) in bytes.iter().enumerate() { + if index >= MAX_VARINT_LEN { + return Err(NetError::MalformedVarint); + } + // Accumulate the seven payload bits at their little-endian position. + value |= u64::from(byte & 0x7f) << shift; + if byte & 0x80 == 0 { + return Ok((value, index + 1)); + } + shift += 7; + } + // The continuation bit was still set when the buffer ran out. + Err(NetError::UnexpectedEof) +} + +/// Encodes `msg` as a single length-prefixed `postcard` frame into a freshly allocated buffer. +fn encode_frame(msg: &T) -> Result, NetError> { + let payload = postcard::to_stdvec(msg)?; + let mut frame = Vec::new(); + write_varint(payload.len() as u64, &mut frame); + frame.extend_from_slice(&payload); + Ok(frame) +} + +/// Writes one length-prefixed `postcard` frame to a quinn send stream. +pub async fn write_frame( + stream: &mut quinn::SendStream, + msg: &T, +) -> Result<(), NetError> { + let frame = encode_frame(msg)?; + stream.write_all(&frame).await?; + Ok(()) +} + +/// Reads one length-prefixed `postcard` frame from a quinn recv stream and decodes it. +pub async fn read_frame( + stream: &mut quinn::RecvStream, + max_len: usize, +) -> Result { + // The prefix length is not known in advance, so bytes are pulled one at a time until a byte without the continuation flag is read, then decoded by the shared pure helper. + let mut prefix = Vec::with_capacity(MAX_VARINT_LEN); + loop { + let mut byte = [0u8; 1]; + stream.read_exact(&mut byte).await?; + prefix.push(byte[0]); + if byte[0] & 0x80 == 0 { + break; + } + if prefix.len() > MAX_VARINT_LEN { + return Err(NetError::MalformedVarint); + } + } + let (len, _consumed) = read_varint(&prefix)?; + + // The declared length is validated before allocating the payload buffer. + let checked_len = check_frame_len(len, max_len)?; + let mut payload = vec![0u8; checked_len]; + stream.read_exact(&mut payload).await?; + Ok(postcard::from_bytes(&payload)?) +} + +/// Validates a declared frame length against `max_len`. +fn check_frame_len(len: u64, max_len: usize) -> Result { + if len > max_len as u64 { + return Err(NetError::FrameTooLarge { len, max: max_len }); + } + #[expect( + clippy::cast_possible_truncation, + reason = "len <= max_len (usize) checked above" + )] + Ok(len as usize) +} + +#[cfg(test)] +mod tests { + use super::*; + use shared::protocol::{ + ClientHello, ControlMessage, FeatureFlags, PROTOCOL_VERSION, PlayerIdentity, + }; + + /// Round-trips a set of boundary values through the varint codec, asserting both the decoded value and the exact byte length consumed. + #[test] + fn varint_round_trip_boundaries() -> Result<(), NetError> { + let cases = [ + 0, + 1, + 127, + 128, + 16_383, + 16_384, + u64::from(u32::MAX), + u64::MAX, + ]; + for value in cases { + let mut buf = Vec::new(); + write_varint(value, &mut buf); + let (decoded, consumed) = read_varint(&buf)?; + assert_eq!(decoded, value, "decoded value mismatch"); + assert_eq!( + consumed, + buf.len(), + "consumed length must equal encoded length" + ); + } + Ok(()) + } + + /// A frame encodes as `[varint payload length][payload]`, and decoding the prefix recovers exactly the payload byte count. + #[test] + fn encode_frame_prefixes_payload_length() -> Result<(), NetError> { + let msg = ControlMessage::ClientHello(ClientHello { + protocol_version: PROTOCOL_VERSION, + client_build: "synvael-client-0.1.0".to_string(), + player_identity: PlayerIdentity { + display_name: "Player1".to_string(), + }, + installed_packs: Vec::new(), + requested_features: FeatureFlags(0), + }); + let payload = postcard::to_stdvec(&msg)?; + let frame = encode_frame(&msg)?; + + let (declared_len, prefix_bytes) = read_varint(&frame)?; + assert_eq!( + declared_len, + payload.len() as u64, + "prefix must equal payload length" + ); + assert_eq!( + &frame[prefix_bytes..], + payload.as_slice(), + "payload bytes must follow the prefix unchanged" + ); + Ok(()) + } + + /// Confirms the documented one/two-byte boundary encodings so a regression in the continuation logic is caught directly. + #[test] + fn varint_boundary_lengths() { + let mut buf = Vec::new(); + write_varint(127, &mut buf); + assert_eq!(buf.len(), 1, "127 must encode in a single byte"); + + buf.clear(); + write_varint(128, &mut buf); + assert_eq!(buf.len(), 2, "128 must encode in two bytes"); + } + + /// A varint whose final byte still sets the continuation bit is a truncated buffer and must error rather than panic. + #[test] + fn varint_truncated_is_error() { + // Two bytes both flagged as "continued", with no terminating byte. + let truncated = [0x80u8, 0x80u8]; + assert!( + read_varint(&truncated).is_err(), + "truncated varint must return an error" + ); + } + + /// An encoding longer than the ten bytes a `u64` can occupy is rejected as overlong rather than silently accepted. + #[test] + fn varint_overlong_is_error() { + // Eleven continuation bytes followed by a terminator exceeds the u64 limit. + let overlong = [0x80u8; 11]; + assert!( + read_varint(&overlong).is_err(), + "overlong varint must return an error" + ); + } + + /// A declared length within the cap is accepted; one exceeding it is rejected as `FrameTooLarge` before any allocation. + #[test] + fn frame_len_bound_is_enforced() { + assert_eq!( + check_frame_len(64, 128).ok(), + Some(64), + "a length within the cap is accepted" + ); + assert_eq!( + check_frame_len(128, 128).ok(), + Some(128), + "a length equal to the cap is accepted" + ); + assert!( + matches!( + check_frame_len(129, 128), + Err(NetError::FrameTooLarge { len: 129, max: 128 }) + ), + "a length over the cap must be rejected", + ); + } +}