From 19157e0722c47d5db92f2ca823ef6e4db01ef6c0 Mon Sep 17 00:00:00 2001 From: reverse Date: Sun, 9 Aug 2026 18:45:12 +0000 Subject: [PATCH] =?UTF-8?q?websocket:=20native=20RFC=206455=20transport=20?= =?UTF-8?q?(ws://=20+=20wss://)=20=E2=80=94=20handshake,=20framing,=20ping?= =?UTF-8?q?/pong=20keepalive,=20idle=20timeout,=20origin/proxy=20flags,=20?= =?UTF-8?q?via=5Fwebsocket?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ircd.rs | 7 + src/lib.rs | 1 + src/main.rs | 3 + src/modules/rpc/user.rs | 1 + src/socketengine.rs | 4 + src/users.rs | 1 + src/websocket.rs | 616 ++++++++++++++++++++++++++++++++++++++++ 7 files changed, 633 insertions(+) create mode 100644 src/websocket.rs diff --git a/src/ircd.rs b/src/ircd.rs index 7e9e54e..4a49860 100644 --- a/src/ircd.rs +++ b/src/ircd.rs @@ -27,6 +27,7 @@ pub enum Event { certfp: Option, // TLS client-cert fingerprint (clients only) link: bool, // a server-to-server connection, not a client outbound: bool, // (link) we dialed them + websocket: bool, // arrived over the WebSocket transport }, Line { uid: Uid, @@ -114,11 +115,17 @@ impl Ircd { certfp, link, outbound, + websocket, } => { if link { self.server.add_link(uid, addr, out, sock, outbound); } else { self.server.add_conn(uid, addr, out, sock, secure, certfp); + if websocket { + if let Some(u) = self.server.users.get_mut(&uid) { + u.flags.via_websocket = true; + } + } } } Event::Line { uid, line } => { diff --git a/src/lib.rs b/src/lib.rs index bb92d35..eb49e19 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -32,4 +32,5 @@ pub mod socketengine; pub mod tls; pub mod users; pub mod watch; +pub mod websocket; pub mod xline; diff --git a/src/main.rs b/src/main.rs index b577189..d80ddee 100644 --- a/src/main.rs +++ b/src/main.rs @@ -104,6 +104,9 @@ fn main() { // optional JSON-RPC-over-HTTP control interface (see crate::modules::rpc) echoircd::modules::rpc::maybe_start(&cfg, tx.clone()); + // optional WebSocket transport for browser IRC clients (see crate::websocket) + echoircd::websocket::maybe_start(&cfg, tx.clone(), counter.clone()); + // dial any autoconnect uplinks (after a short delay so the peer can boot) for block in cfg.links.iter().filter(|b| b.autoconnect) { let addr = format!("{}:{}", block.ip, block.port); diff --git a/src/modules/rpc/user.rs b/src/modules/rpc/user.rs index 3ab7d23..e0de287 100644 --- a/src/modules/rpc/user.rs +++ b/src/modules/rpc/user.rs @@ -64,6 +64,7 @@ fn full(s: &Server, uid: Uid) -> String { ("channels", format!("[{}]", channels.join(","))), ("oper", u.flags.oper.to_string()), ("secure", u.secure.to_string()), + ("websocket", u.flags.via_websocket.to_string()), ]; if let Some(acct) = &u.account { fields.push(("account", qstr(acct))); diff --git a/src/socketengine.rs b/src/socketengine.rs index 4531e04..a2079d9 100644 --- a/src/socketengine.rs +++ b/src/socketengine.rs @@ -187,6 +187,7 @@ pub fn run_reactor(mut listener: MioListener, core: Sender, counter: Arc< certfp: None, link: false, outbound: false, + websocket: false, }) .is_err() { @@ -384,6 +385,7 @@ pub fn accept_loop( certfp: None, link, outbound: false, + websocket: false, }) .is_err() { @@ -432,6 +434,7 @@ pub fn connect_link(addr: &str, core: Sender, counter: Arc) { certfp: None, link: true, outbound: true, + websocket: false, }) .is_err() { @@ -516,6 +519,7 @@ fn tls_conn( certfp, link, outbound: false, + websocket: false, }) .is_err() { diff --git a/src/users.rs b/src/users.rs index 80ef088..5e0294c 100644 --- a/src/users.rs +++ b/src/users.rs @@ -34,6 +34,7 @@ pub struct UserFlags { pub deny_uncommon: bool, // +c (only users sharing a channel may PM you) pub nick_locked: bool, // NICKLOCK: services/oper holds this nick (no self-change) pub via_webirc: bool, // connected through a WEBIRC gateway (securitygroups) + pub via_websocket: bool, // connected over the WebSocket transport (ws://, wss://) pub away: Option, // AWAY message, if set } diff --git a/src/websocket.rs b/src/websocket.rs new file mode 100644 index 0000000..2cd4ea0 --- /dev/null +++ b/src/websocket.rs @@ -0,0 +1,616 @@ +//! WebSocket transport (RFC 6455) — lets browser IRC clients (KiwiIRC, gamja, +//! The Lounge, reverse's Orbit, …) connect straight to echoIRCd, no Node bridge. +//! It's a transport, not a pluggable module, so it lives beside `tls.rs`/`http.rs` +//! at the I/O edge: a thread-per-connection listener that does the HTTP Upgrade +//! handshake, then frames the IRC byte stream in and out of WebSocket frames. One +//! thread owns each socket (like the TLS path) so frames never interleave. +//! +//! Native only: SHA-1 + base64 for the accept key come from OpenSSL; the framing +//! is hand-rolled; no new crate, no `unsafe`. +//! +//! Config (flat keys): +//! bind_ws = 127.0.0.1:8097 plaintext ws:// listener +//! bind_wss = 0.0.0.0:7799 wss:// listener (uses tls_cert/tls_key) +//! ws_origin = https://x.example (repeatable) allowed Origin globs; empty = any +//! ws_handshake_timeout = 10 seconds to finish the Upgrade +//! ws_ping_interval = 60 seconds between server keepalive pings (0 = off) +//! ws_timeout = 120 seconds with no traffic before we drop it +//! ws_trust_proxy = no read X-Forwarded-For / -Proto (behind nginx) + +use std::io::{self, Read, Write}; +use std::net::{IpAddr, Shutdown, SocketAddr, TcpListener, TcpStream}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::mpsc::{Receiver, Sender, TryRecvError}; +use std::sync::Arc; +use std::thread; +use std::time::{Duration, Instant}; + +use crate::channels::glob_match; +use crate::config::Config; +use crate::ircd::Event; +use crate::socketengine::OutSink; +use crate::tls::{OpensslBackend, TlsBackend, TlsConn}; +use crate::Uid; + +/// The RFC 6455 handshake GUID appended to the client key. +const GUID: &str = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; +/// Largest single WebSocket frame payload we'll accept (flood guard). +const MAX_FRAME: usize = 128 * 1024; +/// Largest reassembled message before we drop the connection. +const MAX_MSG: usize = 256 * 1024; +/// How long a session blocks on a read before draining writes / doing keepalive. +const POLL: Duration = Duration::from_millis(100); + +// opcodes +const OP_CONT: u8 = 0x0; +const OP_TEXT: u8 = 0x1; +const OP_BIN: u8 = 0x2; +const OP_CLOSE: u8 = 0x8; +const OP_PING: u8 = 0x9; +const OP_PONG: u8 = 0xA; + +/// Tunables read once from the config. +#[derive(Clone)] +pub struct WsConfig { + origins: Vec, + handshake_timeout: Duration, + ping_interval: Duration, + idle_timeout: Duration, + trust_proxy: bool, + binary_ok: bool, +} + +/// A stream the WS session can drive — implemented for a plaintext `TcpStream` +/// (`ws://`) and a TLS connection (`wss://`), so one session loop serves both. +pub trait WsStream { + fn read(&mut self, buf: &mut [u8]) -> io::Result; + fn write_all(&mut self, buf: &[u8]) -> io::Result<()>; + fn flush(&mut self) -> io::Result<()>; + fn set_read_timeout(&self, dur: Option) -> io::Result<()>; + fn shutdown(&mut self); +} + +impl WsStream for TcpStream { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + Read::read(self, buf) + } + fn write_all(&mut self, buf: &[u8]) -> io::Result<()> { + Write::write_all(self, buf) + } + fn flush(&mut self) -> io::Result<()> { + Write::flush(self) + } + fn set_read_timeout(&self, dur: Option) -> io::Result<()> { + TcpStream::set_read_timeout(self, dur) + } + fn shutdown(&mut self) { + let _ = TcpStream::shutdown(self, Shutdown::Both); + } +} + +impl WsStream for Box { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + (**self).read(buf) + } + fn write_all(&mut self, buf: &[u8]) -> io::Result<()> { + (**self).write_all(buf) + } + fn flush(&mut self) -> io::Result<()> { + (**self).flush() + } + fn set_read_timeout(&self, dur: Option) -> io::Result<()> { + (**self).set_read_timeout(dur) + } + fn shutdown(&mut self) { + (**self).shutdown() + } +} + +/// `Sec-WebSocket-Accept` = base64(SHA1(key + GUID)). +pub fn accept_key(client_key: &str) -> String { + let digest = openssl::hash::hash( + openssl::hash::MessageDigest::sha1(), + format!("{client_key}{GUID}").as_bytes(), + ) + .map(|d| d.to_vec()) + .unwrap_or_default(); + openssl::base64::encode_block(&digest) +} + +/// Start the ws:// and/or wss:// listeners if configured. Called from `main`. +pub fn maybe_start(cfg: &Config, core: Sender, counter: Arc) { + let get = |k: &str| cfg.raw.get(k).and_then(|v| v.last()).map(|s| s.as_str()); + let dur = |k: &str, d: u64| { + get(k) + .and_then(|v| v.parse().ok()) + .map(Duration::from_secs) + .unwrap_or(Duration::from_secs(d)) + }; + let wscfg = WsConfig { + origins: cfg.raw.get("ws_origin").cloned().unwrap_or_default(), + handshake_timeout: dur("ws_handshake_timeout", 10), + ping_interval: dur("ws_ping_interval", 60), + idle_timeout: dur("ws_timeout", 120), + trust_proxy: get("ws_trust_proxy") + .map(crate::config::yesish) + .unwrap_or(false), + binary_ok: true, + }; + + if let Some(bind) = get("bind_ws").map(str::to_string) { + match TcpListener::bind(&bind) { + Ok(l) => { + eprintln!("echoircd WebSocket (ws) on {bind}"); + let (c, n, w) = (core.clone(), counter.clone(), wscfg.clone()); + thread::spawn(move || accept_ws(l, c, n, None, w)); + } + Err(e) => eprintln!("echoircd: cannot bind ws {bind}: {e}"), + } + } + + if let Some(bind) = get("bind_wss").map(str::to_string) { + match (get("tls_cert"), get("tls_key")) { + (Some(cert), Some(key)) => match OpensslBackend::new(cert, key) { + Ok(backend) => match TcpListener::bind(&bind) { + Ok(l) => { + eprintln!("echoircd WebSocket (wss) on {bind} (openssl)"); + let backend: Arc = Arc::new(backend); + let (c, n, w) = (core.clone(), counter.clone(), wscfg.clone()); + thread::spawn(move || accept_ws(l, c, n, Some(backend), w)); + } + Err(e) => eprintln!("echoircd: cannot bind wss {bind}: {e}"), + }, + Err(e) => eprintln!("echoircd: wss disabled (cert/key error): {e}"), + }, + _ => eprintln!("echoircd: bind_wss set but tls_cert/tls_key missing — wss OFF"), + } + } +} + +/// Accept forever; one thread per connection. +fn accept_ws( + listener: TcpListener, + core: Sender, + counter: Arc, + tls: Option>, + cfg: WsConfig, +) { + for conn in listener.incoming() { + let Ok(stream) = conn else { continue }; + let Ok(addr) = stream.peer_addr() else { + continue; + }; + let _ = stream.set_nodelay(true); + let uid = counter.fetch_add(1, Ordering::Relaxed); + let (core, tls, cfg) = (core.clone(), tls.clone(), cfg.clone()); + thread::spawn(move || ws_conn(stream, uid, addr, core, tls, cfg)); + } +} + +/// Per-connection entry: wrap in TLS for wss, keep a raw handle for force-close, +/// then run the generic session. +fn ws_conn( + raw: TcpStream, + uid: Uid, + addr: SocketAddr, + core: Sender, + tls: Option>, + cfg: WsConfig, +) { + let Ok(shutdown) = raw.try_clone() else { + return; + }; + match tls { + Some(backend) => match backend.accept(raw) { + Ok(conn) => ws_session(conn, uid, addr, true, core, shutdown, cfg), + Err(_) => { + let _ = shutdown.shutdown(Shutdown::Both); + } + }, + None => ws_session(raw, uid, addr, false, core, shutdown, cfg), + } +} + +/// The result of a successful handshake. +struct Handshake { + real_ip: Option, + secure: bool, + binary: bool, +} + +/// Drive one WebSocket connection: handshake, then frame IRC lines both ways until +/// close/EOF/idle-timeout or the core drops us. +fn ws_session( + mut stream: S, + uid: Uid, + addr: SocketAddr, + tls_secure: bool, + core: Sender, + shutdown: TcpStream, + cfg: WsConfig, +) { + // --- HTTP Upgrade handshake (bounded by the handshake timeout) --- + let _ = stream.set_read_timeout(Some(cfg.handshake_timeout)); + let hs = match do_handshake(&mut stream, &cfg) { + Ok(h) => h, + Err(_) => { + let _ = shutdown.shutdown(Shutdown::Both); + return; + } + }; + let real_addr = hs + .real_ip + .map(|ip| SocketAddr::new(ip, addr.port())) + .unwrap_or(addr); + let secure = tls_secure || hs.secure; + let send_opcode = if hs.binary { OP_BIN } else { OP_TEXT }; + + let (out_tx, out_rx) = std::sync::mpsc::channel::(); + if core + .send(Event::Connect { + uid, + addr: real_addr, + out: OutSink::Thread(out_tx), + sock: Some(shutdown), + secure, + certfp: None, + link: false, + outbound: false, + websocket: true, + }) + .is_err() + { + stream.shutdown(); + return; + } + + // --- framed I/O loop (one thread, poll-read + drain-writes, like TLS) --- + let _ = stream.set_read_timeout(Some(POLL)); + io_loop(&mut stream, uid, &core, &out_rx, &cfg, send_opcode); + + // best-effort close handshake, then tell the core we're gone + let _ = stream.write_all(&encode(OP_CLOSE, &[])); + stream.shutdown(); + let _ = core.send(Event::Disconnect { uid }); +} + +/// The read/deframe + write/frame loop. Returns when the connection should end. +fn io_loop( + stream: &mut S, + uid: Uid, + core: &Sender, + out_rx: &Receiver, + cfg: &WsConfig, + send_opcode: u8, +) { + let mut acc: Vec = Vec::new(); // raw bytes awaiting a full frame + let mut msg: Vec = Vec::new(); // reassembled data message + let mut chunk = [0u8; 8192]; + let mut last_rx = Instant::now(); + let mut last_ping = Instant::now(); + + loop { + // 1) read + match stream.read(&mut chunk) { + Ok(0) => break, // EOF + Ok(n) => { + last_rx = Instant::now(); + acc.extend_from_slice(&chunk[..n]); + loop { + match parse_frame(&acc) { + Ok(Some((frame, consumed))) => { + acc.drain(..consumed); + match frame.opcode { + OP_CLOSE => return, + OP_PING => { + let _ = stream.write_all(&encode(OP_PONG, &frame.payload)); + } + OP_PONG => {} + OP_TEXT | OP_BIN => { + msg = frame.payload; + if frame.fin && !deliver(&mut msg, uid, core) { + return; + } + } + OP_CONT => { + msg.extend_from_slice(&frame.payload); + if msg.len() > MAX_MSG { + return; + } + if frame.fin && !deliver(&mut msg, uid, core) { + return; + } + } + _ => return, // unknown opcode + } + } + Ok(None) => break, // need more bytes + Err(()) => return, // protocol violation + } + } + } + Err(e) + if matches!( + e.kind(), + io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut + ) => {} + Err(e) if e.kind() == io::ErrorKind::Interrupted => continue, + Err(_) => break, + } + + // 2) drain queued output → frames + loop { + match out_rx.try_recv() { + Ok(line) => { + let mut payload = line.into_bytes(); + payload.extend_from_slice(b"\r\n"); + if stream.write_all(&encode(send_opcode, &payload)).is_err() { + return; + } + } + Err(TryRecvError::Empty) => break, + Err(TryRecvError::Disconnected) => return, // core removed us + } + } + let _ = stream.flush(); + + // 3) keepalive + idle timeout + if cfg.ping_interval > Duration::ZERO && last_ping.elapsed() >= cfg.ping_interval { + last_ping = Instant::now(); + if stream.write_all(&encode(OP_PING, b"echo")).is_err() { + return; + } + } + if last_rx.elapsed() >= cfg.idle_timeout { + return; // dead connection + } + } +} + +/// Split a completed data message into IRC lines and forward them; returns false if +/// the core has gone away. Clears `msg`. +fn deliver(msg: &mut Vec, uid: Uid, core: &Sender) -> bool { + let text = String::from_utf8_lossy(msg); + for piece in text.split('\n') { + let l = piece.trim_end_matches('\r'); + if !l.is_empty() + && core + .send(Event::Line { + uid, + line: l.to_string(), + }) + .is_err() + { + msg.clear(); + return false; + } + } + msg.clear(); + true +} + +/// One decoded WebSocket frame (payload already unmasked). +struct Frame { + fin: bool, + opcode: u8, + payload: Vec, +} + +/// Parse one frame from the front of `buf`, returning it plus the bytes consumed. +/// `Ok(None)` = need more bytes; `Err(())` = protocol violation (caller closes). +/// Client frames must be masked. +fn parse_frame(buf: &[u8]) -> Result, ()> { + if buf.len() < 2 { + return Ok(None); + } + let b0 = buf[0]; + let b1 = buf[1]; + let fin = b0 & 0x80 != 0; + let opcode = b0 & 0x0F; + let masked = b1 & 0x80 != 0; + if !masked { + return Err(()); // RFC 6455 §5.1: client→server frames MUST be masked + } + let len7 = (b1 & 0x7F) as usize; + let mut idx = 2; + let payload_len = match len7 { + 126 => { + if buf.len() < idx + 2 { + return Ok(None); + } + let l = u16::from_be_bytes([buf[idx], buf[idx + 1]]) as usize; + idx += 2; + l + } + 127 => { + if buf.len() < idx + 8 { + return Ok(None); + } + let mut a = [0u8; 8]; + a.copy_from_slice(&buf[idx..idx + 8]); + idx += 8; + u64::from_be_bytes(a) as usize + } + n => n, + }; + if payload_len > MAX_FRAME { + return Err(()); + } + if buf.len() < idx + 4 + payload_len { + return Ok(None); // mask key (4) + payload not fully arrived + } + let mask = [buf[idx], buf[idx + 1], buf[idx + 2], buf[idx + 3]]; + idx += 4; + let mut payload = buf[idx..idx + payload_len].to_vec(); + for (i, b) in payload.iter_mut().enumerate() { + *b ^= mask[i % 4]; + } + Ok(Some(( + Frame { + fin, + opcode, + payload, + }, + idx + payload_len, + ))) +} + +/// Encode a server frame (FIN set, never masked). +fn encode(opcode: u8, payload: &[u8]) -> Vec { + let mut out = Vec::with_capacity(payload.len() + 10); + out.push(0x80 | opcode); + let n = payload.len(); + if n < 126 { + out.push(n as u8); + } else if n <= 0xFFFF { + out.push(126); + out.extend_from_slice(&(n as u16).to_be_bytes()); + } else { + out.push(127); + out.extend_from_slice(&(n as u64).to_be_bytes()); + } + out.extend_from_slice(payload); + out +} + +/// Read and validate the HTTP Upgrade request, then write the 101 response. +fn do_handshake(stream: &mut S, cfg: &WsConfig) -> io::Result { + // read headers (bounded) + let mut buf = Vec::new(); + let mut chunk = [0u8; 2048]; + while !buf.windows(4).any(|w| w == b"\r\n\r\n") { + if buf.len() > 16 * 1024 { + return Err(io::Error::other("headers too large")); + } + let n = stream.read(&mut chunk)?; + if n == 0 { + return Err(io::Error::other("eof in handshake")); + } + buf.extend_from_slice(&chunk[..n]); + } + let head = String::from_utf8_lossy(&buf).into_owned(); + let first = head.lines().next().unwrap_or(""); + if !first + .split_whitespace() + .next() + .is_some_and(|m| m.eq_ignore_ascii_case("GET")) + { + return Err(io::Error::other("not a GET")); + } + let hdr = |name: &str| header(&head, name); + if !hdr("upgrade").is_some_and(|v| v.to_ascii_lowercase().contains("websocket")) + || !hdr("connection").is_some_and(|v| v.to_ascii_lowercase().contains("upgrade")) + { + return Err(io::Error::other("missing upgrade")); + } + let key = hdr("sec-websocket-key").ok_or_else(|| io::Error::other("no key"))?; + + // origin check (CSWSH guard): if any configured, the Origin must match one + if !cfg.origins.is_empty() { + let origin = hdr("origin").unwrap_or_default(); + if !cfg.origins.iter().any(|g| glob_match(g, &origin)) { + let _ = stream.write_all(b"HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n"); + return Err(io::Error::other("origin rejected")); + } + } + + // subprotocol: prefer text.ircv3.net; accept binary.ircv3.net + let offered = hdr("sec-websocket-protocol") + .unwrap_or_default() + .to_ascii_lowercase(); + let (chosen, binary) = if offered.split(',').any(|p| p.trim() == "text.ircv3.net") { + (Some("text.ircv3.net"), false) + } else if cfg.binary_ok && offered.split(',').any(|p| p.trim() == "binary.ircv3.net") { + (Some("binary.ircv3.net"), true) + } else { + (None, false) + }; + + // real IP / scheme from a trusted reverse proxy + let (mut real_ip, mut secure) = (None, false); + if cfg.trust_proxy { + if let Some(xff) = hdr("x-forwarded-for") { + if let Some(ip) = xff.split(',').next().and_then(|s| s.trim().parse().ok()) { + real_ip = Some(ip); + } + } + secure = hdr("x-forwarded-proto").is_some_and(|v| v.eq_ignore_ascii_case("https")); + } + + // 101 response + let mut resp = format!( + "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\ + Sec-WebSocket-Accept: {}\r\n", + accept_key(key.trim()) + ); + if let Some(proto) = chosen { + resp.push_str(&format!("Sec-WebSocket-Protocol: {proto}\r\n")); + } + resp.push_str("\r\n"); + stream.write_all(resp.as_bytes())?; + stream.flush()?; + Ok(Handshake { + real_ip, + secure, + binary, + }) +} + +/// Case-insensitive header lookup from a raw HTTP header block. +fn header(head: &str, name: &str) -> Option { + head.lines().skip(1).find_map(|l| { + let (k, v) = l.split_once(':')?; + k.trim() + .eq_ignore_ascii_case(name) + .then(|| v.trim().to_string()) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accept_key_matches_rfc_example() { + // RFC 6455 §1.3 worked example + assert_eq!( + accept_key("dGhlIHNhbXBsZSBub25jZQ=="), + "s3pPLMBiTxaQ9kYGzzhZRbK+xOo=" + ); + } + + #[test] + fn roundtrip_masked_text_frame() { + // build a masked client TEXT frame carrying "NICK bob" + let payload = b"NICK bob"; + let mask = [0x12u8, 0x34, 0x56, 0x78]; + let mut frame = vec![0x81, 0x80 | payload.len() as u8]; + frame.extend_from_slice(&mask); + for (i, b) in payload.iter().enumerate() { + frame.push(b ^ mask[i % 4]); + } + let (parsed, consumed) = parse_frame(&frame).unwrap().unwrap(); + assert!( + parsed.fin + && parsed.opcode == OP_TEXT + && parsed.payload == payload + && consumed == frame.len() + ); + } + + #[test] + fn unmasked_client_frame_is_rejected() { + assert!(parse_frame(&[0x81, 0x03, b'a', b'b', b'c']).is_err()); + } + + #[test] + fn partial_frame_needs_more() { + assert!(parse_frame(&[0x81]).unwrap().is_none()); + } + + #[test] + fn encode_sets_fin_and_length() { + let f = encode(OP_TEXT, b"hi"); + assert_eq!(f, vec![0x81, 0x02, b'h', b'i']); + } +}