From 286915329a750f3c18deb0e8cde62d028bf9a689 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 12 Jun 2026 05:40:54 +0000 Subject: [PATCH] =?UTF-8?q?feat(ws):=20RFC=206455=20frame=20codec=20?= =?UTF-8?q?=E2=80=94=20decode/encode,=20close=20payloads,=20assembler=20(v?= =?UTF-8?q?0.4=20chunk=202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure module (src/ws/frame.rs): bytes in, frames out, zero io/actor machinery, so the whole RFC 6455 §5 edge surface lives in fast unit tests ahead of the chunk-3 conn-lifecycle wiring. - decode(buf, require_masked, max_payload) -> Ok(Some((frame, consumed))) | Ok(None: incomplete) | Err(fatal). Server mode requires client masking (§5.1); the flag keeps the codec reusable for a client mode. Enforced: RSV=0, reserved opcodes, control frames FIN+<=125, MINIMAL length encodings (16/64-bit), 64-bit MSB clear. The payload cap is checked from the header BEFORE the payload is buffered — a hostile 8-byte length can't make the conn actor allocate toward it. - Frame::encode is unmask-only (server MUST NOT mask); encode_masked exists for the client side of tests + future client mode. - Close payloads: parse_close_payload validates the §7.4 wire-code ranges (1004/1005/1006/1015 and <1000/1012-2999/>=5000 rejected, 3000-4999 app space allowed), 1-byte payload rejected, UTF-8 reason required; close_payload(code, reason) truncates the reason to the 125-byte control cap on a char boundary. - Assembler (data frames only; control frames interleave at the caller per §5.4): orphan continuation and mid-fragmentation data frames are protocol errors, message cap spans fragments (and the unfragmented fast path), text UTF-8 validated once on completion (whole-message delivery makes incremental validation pointless), reusable after every message/error. - FrameError -> close_code mapping: Protocol=1002, TooLarge=1009, BadUtf8=1007. Tests pin all five §5.7 worked examples (masked Hello bytes verbatim), round-trip the 125/126 and 65535/65536 boundaries, and prove decode returns None at EVERY proper prefix of a frame. 57 unit tests total. --- src/ws/frame.rs | 637 ++++++++++++++++++++++++++++++++++++++++++++++++ src/ws/mod.rs | 2 + 2 files changed, 639 insertions(+) create mode 100644 src/ws/frame.rs diff --git a/src/ws/frame.rs b/src/ws/frame.rs new file mode 100644 index 0000000..3a2482a --- /dev/null +++ b/src/ws/frame.rs @@ -0,0 +1,637 @@ +//! RFC 6455 §5 — the frame codec. Pure: bytes in, frames out, no io and +//! no actor machinery, so every edge lives in fast unit tests. The +//! connection actor (chunk 3) owns buffering and the socket. +//! +//! Server-side rules enforced here: +//! - client→server frames MUST be masked (§5.1); decode takes +//! `require_masked` so a future client mode can reuse the codec. +//! - server→client frames are NEVER masked; [`Frame::encode`] doesn't +//! offer masking. [`encode_masked`] exists for the client side of +//! tests. +//! - RSV bits must be 0 (we negotiate no extensions), §5.2. +//! - Payload lengths must use the minimal encoding, §5.2. +//! - Control frames: FIN set, payload ≤ 125, §5.5. +//! +//! Errors map to close codes via [`FrameError::close_code`]: protocol +//! violations → 1002, oversize → 1009, bad UTF-8 in text → 1007. + +/// §5.2 opcodes. Reserved values are rejected at decode. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Opcode { + Continuation, + Text, + Binary, + Close, + Ping, + Pong, +} + +impl Opcode { + fn from_u4(n: u8) -> Option { + Some(match n { + 0x0 => Opcode::Continuation, + 0x1 => Opcode::Text, + 0x2 => Opcode::Binary, + 0x8 => Opcode::Close, + 0x9 => Opcode::Ping, + 0xA => Opcode::Pong, + _ => return None, + }) + } + + fn to_u4(self) -> u8 { + match self { + Opcode::Continuation => 0x0, + Opcode::Text => 0x1, + Opcode::Binary => 0x2, + Opcode::Close => 0x8, + Opcode::Ping => 0x9, + Opcode::Pong => 0xA, + } + } + + pub fn is_control(self) -> bool { + matches!(self, Opcode::Close | Opcode::Ping | Opcode::Pong) + } +} + +/// One decoded frame; payload already unmasked. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Frame { + pub fin: bool, + pub opcode: Opcode, + pub payload: Vec, +} + +impl Frame { + pub fn new(opcode: Opcode, payload: impl Into>) -> Self { + Frame { fin: true, opcode, payload: payload.into() } + } + + /// Serialise unmasked (server→client, §5.1: a server MUST NOT mask). + pub fn encode(&self) -> Vec { + let mut out = Vec::with_capacity(self.payload.len() + 10); + encode_head(&mut out, self.fin, self.opcode, self.payload.len(), None); + out.extend_from_slice(&self.payload); + out + } +} + +/// Why decoding (or assembly) failed. Fatal for the connection: the ws +/// close handshake should carry [`FrameError::close_code`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FrameError { + /// §5.x violation; the str is a debugging breadcrumb, not protocol. + Protocol(&'static str), + /// Frame or assembled message exceeds the configured cap → 1009. + TooLarge, + /// A complete text message that is not valid UTF-8 → 1007. + BadUtf8, +} + +impl FrameError { + pub fn close_code(self) -> u16 { + match self { + FrameError::Protocol(_) => 1002, + FrameError::TooLarge => 1009, + FrameError::BadUtf8 => 1007, + } + } +} + +/// Try to decode one frame from the front of `buf`. +/// +/// - `Ok(Some((frame, consumed)))` — drop `consumed` bytes off the front. +/// - `Ok(None)` — incomplete; read more. (Header-derived sizes are still +/// bounds-checked first, so a hostile 8-byte length can't make the +/// caller buffer toward it: oversize fails *before* the payload +/// arrives.) +/// - `Err(_)` — fatal; start the close handshake with the mapped code. +pub fn decode( + buf: &[u8], + require_masked: bool, + max_payload: usize, +) -> Result, FrameError> { + if buf.len() < 2 { + return Ok(None); + } + let b0 = buf[0]; + let b1 = buf[1]; + + let fin = b0 & 0x80 != 0; + if b0 & 0x70 != 0 { + return Err(FrameError::Protocol("RSV bits set without extension")); + } + let opcode = Opcode::from_u4(b0 & 0x0F) + .ok_or(FrameError::Protocol("reserved opcode"))?; + + let masked = b1 & 0x80 != 0; + if require_masked && !masked { + return Err(FrameError::Protocol("unmasked client frame")); + } + + if opcode.is_control() { + if !fin { + return Err(FrameError::Protocol("fragmented control frame")); + } + if b1 & 0x7F > 125 { + return Err(FrameError::Protocol("control payload > 125")); + } + } + + // Payload length: 7-bit, or 126 + u16, or 127 + u64 — minimal form + // required (§5.2). + let (len, mut pos): (u64, usize) = match b1 & 0x7F { + 126 => { + if buf.len() < 4 { + return Ok(None); + } + let l = u16::from_be_bytes([buf[2], buf[3]]) as u64; + if l < 126 { + return Err(FrameError::Protocol("non-minimal 16-bit length")); + } + (l, 4) + } + 127 => { + if buf.len() < 10 { + return Ok(None); + } + let l = u64::from_be_bytes(buf[2..10].try_into().unwrap()); + if l & (1 << 63) != 0 { + return Err(FrameError::Protocol("64-bit length MSB set")); + } + if l < 65536 { + return Err(FrameError::Protocol("non-minimal 64-bit length")); + } + (l, 10) + } + n => (n as u64, 2), + }; + + // Cap check BEFORE waiting for the payload (see decode docs). + if len > max_payload as u64 { + return Err(FrameError::TooLarge); + } + let len = len as usize; + + let mask_key: Option<[u8; 4]> = if masked { + if buf.len() < pos + 4 { + return Ok(None); + } + let k = [buf[pos], buf[pos + 1], buf[pos + 2], buf[pos + 3]]; + pos += 4; + Some(k) + } else { + None + }; + + if buf.len() < pos + len { + return Ok(None); + } + + let mut payload = buf[pos..pos + len].to_vec(); + if let Some(key) = mask_key { + for (i, b) in payload.iter_mut().enumerate() { + *b ^= key[i & 3]; + } + } + + Ok(Some((Frame { fin, opcode, payload }, pos + len))) +} + +fn encode_head( + out: &mut Vec, + fin: bool, + opcode: Opcode, + len: usize, + mask: Option<[u8; 4]>, +) { + let mask_bit = if mask.is_some() { 0x80 } else { 0 }; + out.push(if fin { 0x80 } else { 0 } | opcode.to_u4()); + if len <= 125 { + out.push(mask_bit | len as u8); + } else if len <= u16::MAX as usize { + out.push(mask_bit | 126); + out.extend_from_slice(&(len as u16).to_be_bytes()); + } else { + out.push(mask_bit | 127); + out.extend_from_slice(&(len as u64).to_be_bytes()); + } + if let Some(k) = mask { + out.extend_from_slice(&k); + } +} + +/// Client-side serialisation (masked). Servers never call this in +/// production — it exists so tests (and an eventual client mode) can +/// produce conformant client frames. +pub fn encode_masked(frame: &Frame, key: [u8; 4]) -> Vec { + let mut out = Vec::with_capacity(frame.payload.len() + 14); + encode_head(&mut out, frame.fin, frame.opcode, frame.payload.len(), Some(key)); + out.extend(frame.payload.iter().enumerate().map(|(i, b)| b ^ key[i & 3])); + out +} + +// --------------------------------------------------------------------------- +// Close payload (§5.5.1, §7.4) +// --------------------------------------------------------------------------- + +/// Parse a close frame payload: empty is fine (`None`); otherwise a +/// 2-byte code + optional UTF-8 reason. A 1-byte payload, an invalid +/// wire code, or a non-UTF-8 reason are protocol errors. +pub fn parse_close_payload(payload: &[u8]) -> Result, FrameError> { + match payload.len() { + 0 => Ok(None), + 1 => Err(FrameError::Protocol("1-byte close payload")), + _ => { + let code = u16::from_be_bytes([payload[0], payload[1]]); + if !close_code_valid_on_wire(code) { + return Err(FrameError::Protocol("invalid close code")); + } + let reason = std::str::from_utf8(&payload[2..]) + .map_err(|_| FrameError::Protocol("close reason not UTF-8"))?; + Ok(Some((code, reason.to_string()))) + } + } +} + +/// §7.4.1/.2: codes an endpoint may put on the wire. 1004 is reserved; +/// 1005/1006/1015 are signalling-only (MUST NOT appear in a close +/// frame); 1000–1011 otherwise fine; 3000–4999 are app/registry space. +fn close_code_valid_on_wire(code: u16) -> bool { + matches!(code, 1000..=1003 | 1007..=1011 | 3000..=4999) +} + +/// Build a close frame payload for `code` (+ optional reason, truncated +/// to fit the 125-byte control cap on a UTF-8 boundary). +pub fn close_payload(code: u16, reason: &str) -> Vec { + let mut p = Vec::with_capacity(2 + reason.len().min(123)); + p.extend_from_slice(&code.to_be_bytes()); + let mut cut = reason.len().min(123); + while !reason.is_char_boundary(cut) { + cut -= 1; + } + p.extend_from_slice(&reason.as_bytes()[..cut]); + p +} + +// --------------------------------------------------------------------------- +// Message assembly (§5.4 fragmentation) +// --------------------------------------------------------------------------- + +/// A complete application message. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Message { + Text(String), + Binary(Vec), +} + +/// Reassembles data frames into [`Message`]s. Feed it ONLY data frames +/// (Text/Binary/Continuation) — control frames interleave at the caller +/// (§5.4 allows them mid-fragmentation) and never enter the assembler. +/// +/// Enforced here: continuation with nothing in progress; a new data +/// frame while a fragmented message is in progress; total message size +/// (1009); text UTF-8 validity, checked once on the complete message +/// (we deliver whole messages, so per-frame incremental validation buys +/// nothing). +#[derive(Debug)] +pub struct Assembler { + buf: Vec, + in_progress: Option, // Text or Binary + max_message: usize, +} + +impl Assembler { + pub fn new(max_message: usize) -> Self { + Assembler { buf: Vec::new(), in_progress: None, max_message } + } + + pub fn push(&mut self, frame: Frame) -> Result, FrameError> { + match (frame.opcode, self.in_progress) { + (Opcode::Text | Opcode::Binary, Some(_)) => { + return Err(FrameError::Protocol("new data frame mid-fragmentation")); + } + (Opcode::Continuation, None) => { + return Err(FrameError::Protocol("continuation without a message")); + } + (Opcode::Text | Opcode::Binary, None) if frame.fin => { + // Unfragmented fast path: no buffer copy. + if frame.payload.len() > self.max_message { + return Err(FrameError::TooLarge); + } + return Self::complete(frame.opcode, frame.payload).map(Some); + } + (Opcode::Text | Opcode::Binary, None) => { + self.in_progress = Some(frame.opcode); + } + (Opcode::Continuation, Some(_)) => {} + (op, _) if op.is_control() => { + return Err(FrameError::Protocol("control frame fed to assembler")); + } + _ => unreachable!(), + } + + if self.buf.len() + frame.payload.len() > self.max_message { + self.reset(); + return Err(FrameError::TooLarge); + } + self.buf.extend_from_slice(&frame.payload); + + if frame.fin { + let kind = self.in_progress.take().expect("checked above"); + let payload = std::mem::take(&mut self.buf); + return Self::complete(kind, payload).map(Some); + } + Ok(None) + } + + fn complete(kind: Opcode, payload: Vec) -> Result { + match kind { + Opcode::Binary => Ok(Message::Binary(payload)), + Opcode::Text => String::from_utf8(payload) + .map(Message::Text) + .map_err(|_| FrameError::BadUtf8), + _ => unreachable!(), + } + } + + fn reset(&mut self) { + self.buf.clear(); + self.in_progress = None; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const MAX: usize = 1 << 20; + + fn dec(buf: &[u8]) -> Result, FrameError> { + decode(buf, true, MAX) + } + + // ----- §5.7 worked examples. ----- + + #[test] + fn rfc_masked_hello() { + // "A single-frame masked text message" containing "Hello". + let wire = [0x81, 0x85, 0x37, 0xfa, 0x21, 0x3d, 0x7f, 0x9f, 0x4d, 0x51, 0x58]; + let (f, used) = dec(&wire).unwrap().unwrap(); + assert_eq!(used, wire.len()); + assert!(f.fin); + assert_eq!(f.opcode, Opcode::Text); + assert_eq!(f.payload, b"Hello"); + } + + #[test] + fn rfc_unmasked_hello_rejected_then_allowed() { + let wire = [0x81, 0x05, b'H', b'e', b'l', b'l', b'o']; + assert_eq!( + dec(&wire), + Err(FrameError::Protocol("unmasked client frame")) + ); + // Same bytes are fine when masking isn't required (client mode). + let (f, _) = decode(&wire, false, MAX).unwrap().unwrap(); + assert_eq!(f.payload, b"Hello"); + } + + #[test] + fn rfc_fragmented_hel_lo() { + let f1 = [0x01, 0x03, b'H', b'e', b'l']; + let f2 = [0x80, 0x02, b'l', b'o']; + let (a, _) = decode(&f1, false, MAX).unwrap().unwrap(); + let (b, _) = decode(&f2, false, MAX).unwrap().unwrap(); + assert!(!a.fin); + assert_eq!(a.opcode, Opcode::Text); + assert_eq!(b.opcode, Opcode::Continuation); + + let mut asm = Assembler::new(MAX); + assert_eq!(asm.push(a).unwrap(), None); + assert_eq!( + asm.push(b).unwrap(), + Some(Message::Text("Hello".into())) + ); + } + + #[test] + fn rfc_ping_pong_hello() { + let ping = [0x89, 0x05, b'H', b'e', b'l', b'l', b'o']; + let (f, _) = decode(&ping, false, MAX).unwrap().unwrap(); + assert_eq!(f.opcode, Opcode::Ping); + assert_eq!(f.payload, b"Hello"); + // Pong response carries the same payload back, unmasked. + let pong = Frame::new(Opcode::Pong, f.payload.clone()).encode(); + assert_eq!(pong, [0x8A, 0x05, b'H', b'e', b'l', b'l', b'o']); + } + + #[test] + fn rfc_256_bytes_binary_16bit_len() { + let payload = vec![0xAB; 256]; + let wire = Frame::new(Opcode::Binary, payload.clone()).encode(); + assert_eq!(&wire[..4], &[0x82, 0x7E, 0x01, 0x00]); + let (f, used) = decode(&wire, false, MAX).unwrap().unwrap(); + assert_eq!(used, wire.len()); + assert_eq!(f.payload, payload); + } + + #[test] + fn rfc_64k_binary_64bit_len() { + let payload = vec![7u8; 65536]; + let wire = Frame::new(Opcode::Binary, payload.clone()).encode(); + assert_eq!(&wire[..10], &[0x82, 0x7F, 0, 0, 0, 0, 0, 1, 0, 0]); + let (f, _) = decode(&wire, false, MAX).unwrap().unwrap(); + assert_eq!(f.payload.len(), 65536); + } + + // ----- round trips. ----- + + #[test] + fn masked_roundtrip_all_lengths() { + // Cross the 125/126 and 65535/65536 encoding boundaries. + for len in [0, 1, 125, 126, 127, 65535, 65536] { + let f = Frame::new(Opcode::Binary, vec![0x5A; len]); + let wire = encode_masked(&f, [0xDE, 0xAD, 0xBE, 0xEF]); + let (g, used) = decode(&wire, true, 1 << 20).unwrap().unwrap(); + assert_eq!(used, wire.len(), "len {len}"); + assert_eq!(g, f, "len {len}"); + } + } + + #[test] + fn decode_is_incremental_at_every_prefix() { + let f = Frame::new(Opcode::Text, b"incremental".to_vec()); + let wire = encode_masked(&f, [1, 2, 3, 4]); + for cut in 0..wire.len() { + assert_eq!(dec(&wire[..cut]).unwrap(), None, "prefix {cut}"); + } + assert!(dec(&wire).unwrap().is_some()); + } + + #[test] + fn decode_leaves_trailing_bytes() { + let f = Frame::new(Opcode::Text, b"a".to_vec()); + let mut wire = encode_masked(&f, [9, 9, 9, 9]); + let first_len = wire.len(); + wire.extend_from_slice(&[0x81]); // start of a second frame + let (_, used) = dec(&wire).unwrap().unwrap(); + assert_eq!(used, first_len); + } + + // ----- protocol violations. ----- + + #[test] + fn rsv_bits_rejected() { + for rsv in [0x40, 0x20, 0x10] { + let wire = [0x81 | rsv, 0x80, 0, 0, 0, 0]; + assert!(matches!(dec(&wire), Err(FrameError::Protocol(_))), "rsv {rsv:#x}"); + } + } + + #[test] + fn reserved_opcodes_rejected() { + for op in [0x3, 0x4, 0x5, 0x6, 0x7, 0xB, 0xC, 0xD, 0xE, 0xF] { + let wire = [0x80 | op, 0x80, 0, 0, 0, 0]; + assert!(matches!(dec(&wire), Err(FrameError::Protocol(_))), "op {op:#x}"); + } + } + + #[test] + fn fragmented_control_rejected() { + let wire = [0x09, 0x80, 0, 0, 0, 0]; // ping, FIN=0 + assert_eq!(dec(&wire), Err(FrameError::Protocol("fragmented control frame"))); + } + + #[test] + fn oversize_control_rejected() { + let wire = [0x89, 0x80 | 126, 0x00, 0x7E]; // ping, 16-bit len 126 + assert_eq!(dec(&wire), Err(FrameError::Protocol("control payload > 125"))); + } + + #[test] + fn non_minimal_lengths_rejected() { + // 16-bit form carrying 5. + let w16 = [0x81, 0x80 | 126, 0x00, 0x05]; + assert_eq!(dec(&w16), Err(FrameError::Protocol("non-minimal 16-bit length"))); + // 64-bit form carrying 5. + let w64 = [0x81, 0x80 | 127, 0, 0, 0, 0, 0, 0, 0, 0x05]; + assert_eq!(dec(&w64), Err(FrameError::Protocol("non-minimal 64-bit length"))); + } + + #[test] + fn msb_set_64bit_length_rejected() { + let wire = [0x81, 0x80 | 127, 0x80, 0, 0, 0, 0, 0, 0, 0]; + assert_eq!(dec(&wire), Err(FrameError::Protocol("64-bit length MSB set"))); + } + + #[test] + fn oversize_fails_before_payload_arrives() { + // Header promises 1 MiB + 1 with a 1 MiB cap: must fail with just + // the header in hand, not Ok(None). + let mut wire = vec![0x82, 0x80 | 127]; + wire.extend_from_slice(&((MAX as u64) + 1).to_be_bytes()); + assert_eq!(dec(&wire), Err(FrameError::TooLarge)); + } + + // ----- close payloads. ----- + + #[test] + fn close_payload_parsing() { + assert_eq!(parse_close_payload(&[]), Ok(None)); + assert_eq!( + parse_close_payload(&[0x03]), + Err(FrameError::Protocol("1-byte close payload")) + ); + let normal = close_payload(1000, "bye"); + assert_eq!(parse_close_payload(&normal), Ok(Some((1000, "bye".into())))); + // Signalling-only and reserved codes are invalid on the wire. + for bad in [999u16, 1004, 1005, 1006, 1015, 1100, 2999, 5000] { + assert!( + parse_close_payload(&bad.to_be_bytes()).is_err(), + "code {bad}" + ); + } + for good in [1000u16, 1001, 1002, 1003, 1007, 1011, 3000, 4999] { + assert!( + parse_close_payload(&good.to_be_bytes()).is_ok(), + "code {good}" + ); + } + } + + #[test] + fn close_payload_reason_truncates_on_char_boundary() { + let reason = "é".repeat(100); // 200 bytes of 2-byte chars + let p = close_payload(1000, &reason); + assert!(p.len() <= 125); + assert_eq!(p.len() % 2, 0, "must not split the 2-byte char"); + assert!(std::str::from_utf8(&p[2..]).is_ok()); + } + + // ----- assembler. ----- + + #[test] + fn assembler_rejects_orphan_continuation() { + let mut a = Assembler::new(MAX); + let f = Frame { fin: true, opcode: Opcode::Continuation, payload: vec![] }; + assert_eq!( + a.push(f), + Err(FrameError::Protocol("continuation without a message")) + ); + } + + #[test] + fn assembler_rejects_interleaved_data_message() { + let mut a = Assembler::new(MAX); + let start = Frame { fin: false, opcode: Opcode::Text, payload: b"a".to_vec() }; + a.push(start).unwrap(); + let intruder = Frame::new(Opcode::Binary, b"b".to_vec()); + assert_eq!( + a.push(intruder), + Err(FrameError::Protocol("new data frame mid-fragmentation")) + ); + } + + #[test] + fn assembler_message_size_cap_spans_fragments() { + let mut a = Assembler::new(10); + let f1 = Frame { fin: false, opcode: Opcode::Binary, payload: vec![0; 6] }; + let f2 = Frame { fin: true, opcode: Opcode::Continuation, payload: vec![0; 6] }; + assert_eq!(a.push(f1).unwrap(), None); + assert_eq!(a.push(f2), Err(FrameError::TooLarge)); + // And the unfragmented fast path is capped too. + assert_eq!( + a.push(Frame::new(Opcode::Binary, vec![0; 11])), + Err(FrameError::TooLarge) + ); + } + + #[test] + fn assembler_text_utf8_validated_on_completion() { + let mut a = Assembler::new(MAX); + // A 2-byte UTF-8 char split across the fragment boundary must + // still assemble — validation is whole-message. + let bytes = "héllo".as_bytes(); + let f1 = Frame { fin: false, opcode: Opcode::Text, payload: bytes[..2].to_vec() }; + let f2 = Frame { fin: true, opcode: Opcode::Continuation, payload: bytes[2..].to_vec() }; + assert_eq!(a.push(f1).unwrap(), None); + assert_eq!(a.push(f2).unwrap(), Some(Message::Text("héllo".into()))); + + // Invalid UTF-8 → BadUtf8 (close 1007). + let bad = Frame::new(Opcode::Text, vec![0xFF, 0xFE]); + assert_eq!(a.push(bad), Err(FrameError::BadUtf8)); + assert_eq!(FrameError::BadUtf8.close_code(), 1007); + } + + #[test] + fn assembler_reusable_after_message() { + let mut a = Assembler::new(MAX); + for _ in 0..3 { + let f1 = Frame { fin: false, opcode: Opcode::Text, payload: b"He".to_vec() }; + let f2 = Frame { fin: true, opcode: Opcode::Continuation, payload: b"llo".to_vec() }; + assert_eq!(a.push(f1).unwrap(), None); + assert_eq!(a.push(f2).unwrap(), Some(Message::Text("Hello".into()))); + } + } +} diff --git a/src/ws/mod.rs b/src/ws/mod.rs index d0eb6ee..0f59f80 100644 --- a/src/ws/mod.rs +++ b/src/ws/mod.rs @@ -12,8 +12,10 @@ //! vendoring. Base64 here is the 20-byte *encode* direction only and is //! not cryptographic, so that stays in-tree (`handshake::b64`). +pub mod frame; pub mod handshake; +pub use frame::{Frame, FrameError, Message, Opcode}; pub use handshake::Rejection; /// Marker carried on a [`Conn`](crate::Conn) whose handler accepted a