Project homepage Mailing List  Warmcat.com  API Docs  Github Mirror 
    npro  
 Modern all-safe Rust Network Protocol library supporting h1, h2, h3, ws, wt sans-IO and with socket IO + tls
git clone https://npro.rs/repo/npro
 
root / crates / npro-test / transcripts / h1-reqline-leading-empty-many.json
Author[]Andy Green <andy@warmcat.com> 2026-10-05 16:38 UTC
Committer[]Andy Green <andy@warmcat.com> 2026-10-05 19:18 UTC
Tree1f263793b76a87c2fe6df5943549450f0ac46a74   Raw Patch
 
npro-ws: the client's ws handshake and connection
npro-ws: the client's ws handshake and connection

The client half of phase 1e.

 - handshake::ClientKey is C's lws_generate_client_ws_handshake() and
   lws_client_ws_upgrade(): a key of 16 bytes in one draw from the
   caller's random; the request's upgrade lines in C's order, for the h1
   client's Connection::Upgrade; and C's checks of the response in C's
   order, each refusal C's reason: a 101, an accept, "Upgrade:
   websocket", "upgrade" among the Connection tokens, a subprotocol, if
   named, that was offered, no extension, and the key's accept.

 - conn::Ws is one parser for both ends, as the plan has it, where C has
   two: Ws<AsServer> or Ws<AsClient<R>>, a sealed Role.  A client masks
   each frame with four bytes drawn from R when the frame is begun, as
   C's lws_write() draws them, and fails the connection if R has none.
   Each end's checks are in its C parser's order; the client's add "srv
   mask" and "bad fin", call a reserved control opcode without FIN "bad
   opc", and take 1012 to 1015 from a server.

 - After a refusal, as C, the rest of the read is dropped, and once our
   close has gone, whatever comes, the ack or not, ends the connection:
   C reads after its close only to see that.

C's client takes any Connection token that starts "upgrade", comparing
only the token's length of it; npro takes only "upgrade".

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019kg5Eemy68ZaqDBcUJQG6J
diff --git a/crates/npro-ws/Cargo.toml b/crates/npro-ws/Cargo.toml index 5a83646..5ba6b5a 100644 --- a/crates/npro-ws/Cargo.toml +++ b/crates/npro-ws/Cargo.toml @@ -15,5 +15,8 @@ repository.workspace = true npro-core.workspace = true npro-h1.workspace = true +[dev-dependencies] +npro-core = { workspace = true, features = ["replay"] } + [lints] workspace = true diff --git a/crates/npro-ws/src/conn.rs b/crates/npro-ws/src/conn.rs index 7de04e1..455614e 100644 --- a/crates/npro-ws/src/conn.rs +++ b/crates/npro-ws/src/conn.rs @@ -1,5 +1,9 @@ -//! A ws connection: C's `lws_ws_rx_sm()`, its writeable handling and its -//! close, sans-IO. +//! A ws connection: C's `lws_ws_rx_sm()` and `lws_ws_client_rx_sm()`, its +//! writeable handling and its close, sans-IO. One parser serves both ends, +//! where C has two: a [`Ws`] is a server's ([`Ws::server`]) or a client's +//! ([`Ws::client`]), which masks each frame it writes with four bytes drawn +//! from its random source when the frame is begun, as C's `lws_write()` +//! draws them. //! //! [`Ws::rx`] takes the peer's bytes, at most one thing each call, and //! unmasks a frame's payload where it lies in the input, so a message's @@ -9,21 +13,28 @@ //! Control frames, at most 125 bytes, are gathered here: a ping is answered //! with a pong, a pong is given to the application, and the peer's close //! is given to it and answered with the peer's own payload, its code made -//! 1002 if it is one no peer may send. +//! 1002 if it is one the peer may not send (a client, as C's, takes 1012 to +//! 1015 from a server). //! -//! What C refuses, this refuses, with C's close code and reason: a -//! fragmented control frame ("frag ctl"), a reserved opcode ("bad opc"), a -//! continuation out of place ("bad cont"), RSV bits ("rsv bits"), a server's -//! unmasked frame ("client unmasked"), a long control frame ("ctl len"), a -//! length with its top bit ("bad len"), all 1002; a frame longer than C's -//! 256MiB, 1009 "huge frame"; text that is not UTF-8, 1007 "bad utf8" or -//! "partial utf8". After its close, the connection reads nothing more. +//! What C refuses, this refuses, with C's close code and reason, each end +//! in its C parser's order: a fragmented control frame ("frag ctl"), a +//! reserved opcode ("bad opc"), a continuation out of place ("bad cont"), +//! RSV bits ("rsv bits"), a client's message begun while one is open ("bad +//! fin", which a server calls "bad cont"), a frame masked or not as the +//! side forbids ("client unmasked", "srv mask"), a long control frame ("ctl +//! len"), a length with its top bit ("bad len"), all 1002; a frame longer +//! than C's 256MiB, 1009 "huge frame"; text that is not UTF-8, 1007 "bad +//! utf8" or "partial utf8". As C, the rest of that read is dropped; what +//! comes after it is dropped until our close has gone, and then ends the +//! connection, the peer's ack or not. After the peer's close, nothing more +//! is read. //! //! [`Ws::tx`] writes in C's order: what is in flight first (the 101, a frame //! begun), then our own close, then the pong, then the answer to the peer's //! close, then the application's next frame, whose payload it pulls. A //! pong still owed when we begin a close is forgotten, as C forgets it. +use npro_core::random::{Random, Unavailable}; use npro_core::utf8::Utf8Validator; use npro_h1::server::TxSource; @@ -38,6 +49,66 @@ const MAX_CTL: usize = 125; pub enum Side { /// A server: the client's frames must be masked, ours are not. Server, + /// A client: the server's frames must not be masked, ours are. + Client, +} + +/// The sealed trait pattern: public, so it can bound [`Role`], and +/// unnameable outside, so nothing else implements it. +mod sealed { + pub trait Sealed {} +} + +/// Which end a [`Ws`] is, and for a client, where its masks come from. +/// Sealed: [`AsServer`] and [`AsClient`] are the two there are. +pub trait Role: sealed::Sealed { + /// Which end this is. + fn side(&self) -> Side; + + /// The mask for the next frame written: `None` for a server; for a + /// client, a draw of four bytes, as C's `lws_write()` draws one per + /// frame. + /// + /// # Errors + /// + /// [`Unavailable`] if the random source has none to give. + fn next_mask(&mut self) -> Result<Option<[u8; 4]>, Unavailable>; +} + +/// A server's end: [`Ws::server`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct AsServer; + +impl sealed::Sealed for AsServer {} + +impl Role for AsServer { + fn side(&self) -> Side { + Side::Server + } + + fn next_mask(&mut self) -> Result<Option<[u8; 4]>, Unavailable> { + Ok(None) + } +} + +/// A client's end, masking with what `R` draws: [`Ws::client`]. +#[derive(Clone, Debug)] +pub struct AsClient<R> { + random: R, +} + +impl<R> sealed::Sealed for AsClient<R> {} + +impl<R: Random> Role for AsClient<R> { + fn side(&self) -> Side { + Side::Client + } + + fn next_mask(&mut self) -> Result<Option<[u8; 4]>, Unavailable> { + let mut mask = [0u8; 4]; + self.random.fill(&mut mask)?; + Ok(Some(mask)) + } } /// What a message is. @@ -84,7 +155,8 @@ pub struct Rx<'a> { pub enum Close { /// Stop sending once what was written has gone. Shutdown, - /// Release it: the close handshake is over. + /// Release it now: the close handshake is over, or, for a client + /// whose random source failed it, the connection cannot go on. Release, } @@ -93,11 +165,17 @@ pub enum Close { pub enum SendError { /// A frame is still going, or the connection is closing. Busy, + /// A client's random source had no mask to give: the connection is + /// failed, as C fails a short `lws_get_random()`. + NoMask, } impl core::fmt::Display for SendError { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - f.write_str("a frame is still going, or the connection is closing") + f.write_str(match self { + Self::Busy => "a frame is still going, or the connection is closing", + Self::NoMask => "no random for the frame's mask", + }) } } @@ -155,6 +233,11 @@ enum Parse { Payload(Frame, u64), /// Nothing more is read. Stopped, + /// We refused the peer's frames. What it sends is not read: until our + /// close has gone, it is dropped; after, anything ends the connection, + /// as in C, where the rest of the read is dropped and whatever comes + /// next, the ack or a frame refused again, closes it. + Refused, } /// Where a message is, between frames. @@ -257,6 +340,22 @@ impl Out { } } + /// Adds `b` after what is there. + fn push(&mut self, b: &[u8]) { + let end = self.len.saturating_add(b.len()); + if let Some(d) = self.buf.get_mut(self.len..end) { + d.copy_from_slice(b); + self.len = end; + } + } + + /// Masks the last `n` bytes, a payload, with `mask`. + fn mask_tail(&mut self, n: usize, mask: [u8; 4]) { + if let Some(t) = self.buf.get_mut(self.len.saturating_sub(n)..self.len) { + apply_mask(t, mask, 0); + } + } + fn drain(&mut self, out: &mut [u8]) -> usize { let rest = self.buf.get(self.sent..self.len).unwrap_or_default(); let n = rest.len().min(out.len()); @@ -268,15 +367,29 @@ impl Out { } } -/// The application's frame, its header written or not, its payload owed. +/// The application's frame, its header written or not, its payload owed, +/// and a client's mask with how far into the payload it has come. #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum App { Idle, - Sending { owed: u64 }, + Sending { + owed: u64, + mask: Option<[u8; 4]>, + at: u64, + }, } -/// A frame's header, unmasked, as a server writes it. -fn frame_header(op: Op, len: u64, out: &mut Out) { +/// Masks `b`, the bytes of a payload from `at` on, with `mask`. +fn apply_mask(b: &mut [u8], mask: [u8; 4], at: u64) { + // the mask's index where `b` starts: `at` mod 4 + let start = at.to_le_bytes().first().map_or(0, |l| usize::from(l & 3)); + for (x, m) in b.iter_mut().zip(mask.iter().cycle().skip(start)) { + *x ^= m; + } +} + +/// A final frame's header, with a client's mask after it. +fn frame_header(op: Op, len: u64, mask: Option<[u8; 4]>, out: &mut Out) { // the length is 7 bits, or 126 and 16 bits, or 127 and 64 bits: the // last of its big endian bytes, after the marker let be = len.to_be_bytes(); @@ -285,31 +398,39 @@ fn frame_header(op: Op, len: u64, out: &mut Out) { Ok(_) | Err(_) if u16::try_from(len).is_ok() => (126, 2), Ok(_) | Err(_) => (127, 8), }; - let mut header = [0x80 | op.code(), marker, 0, 0, 0, 0, 0, 0, 0, 0]; - let used = 2usize.saturating_add(extra); + let masked = if mask.is_some() { 0x80 } else { 0 }; + let mut header = [0u8; 14]; + if let Some(h) = header.get_mut(..2) { + h.copy_from_slice(&[0x80 | op.code(), masked | marker]); + } + let mut used = 2usize.saturating_add(extra); if let (Some(d), Some(s)) = ( header.get_mut(2..used), be.get(be.len().saturating_sub(extra)..), ) { d.copy_from_slice(s); } + if let Some(m) = mask { + let end = used.saturating_add(4); + if let Some(d) = header.get_mut(used..end) { + d.copy_from_slice(&m); + } + used = end; + } out.set(header.get(..used).unwrap_or_default()); } -/// A control frame, header and payload. -fn control_frame(op: Op, c: &Ctl, out: &mut Out) { - let p = c.payload(); - let mut b = [0u8; 2 + MAX_CTL]; - if let Some(h) = b.get_mut(..2) { - h.copy_from_slice(&[0x80 | op.code(), c.len]); +/// A control frame, header and payload, masked with a client's mask. +fn control_frame(op: Op, c: &Ctl, mask: Option<[u8; 4]>, out: &mut Out) { + frame_header(op, u64::from(c.len), mask, out); + out.push(c.payload()); + if let Some(m) = mask { + out.mask_tail(c.payload().len(), m); } - if let Some(d) = b.get_mut(2..2usize.saturating_add(p.len())) { - d.copy_from_slice(p); - } - out.set(b.get(..2usize.saturating_add(p.len())).unwrap_or_default()); } -/// One ws connection. +/// One ws connection, its end `P`: [`AsServer`], or [`AsClient`] with its +/// random source. /// /// ``` /// use npro_ws::conn::{Event, Kind, Ws}; @@ -324,7 +445,8 @@ fn control_frame(op: Op, c: &Ctl, out: &mut Out) { /// ); /// ``` #[derive(Clone, Debug)] -pub struct Ws { +pub struct Ws<P = AsServer> { + role: P, parse: Parse, msg: Msg, utf8: Utf8Validator, @@ -337,14 +459,34 @@ pub struct Ws { app: App, } -impl Ws { +impl Ws<AsServer> { /// A server's connection, `first` being what goes before its frames: /// the 101. #[must_use] pub fn server(first: &[u8]) -> Self { + Self::new(AsServer, first) + } +} + +impl<R: Random> Ws<AsClient<R>> { + /// A client's connection, once the server's 101 has been checked + /// ([`crate::handshake::ClientKey::check`]), masking its frames with + /// what `random` draws. + /// + /// A real connection's source must be one the server cannot predict: + /// see [`npro_core::random::Random`]. + #[must_use] + pub fn client(random: R) -> Self { + Self::new(AsClient { random }, b"") + } +} + +impl<P: Role> Ws<P> { + fn new(role: P, first: &[u8]) -> Self { let mut out = Out::new(); out.set(first); Self { + role, parse: Parse::First, msg: Msg::Idle, utf8: Utf8Validator::new(), @@ -356,6 +498,12 @@ impl Ws { } } + /// Which end this is. + #[must_use] + pub fn side(&self) -> Side { + self.role.side() + } + /// What the connection asks of its carrier, once it is done with. #[must_use] pub const fn close(&self) -> Option<Close> { @@ -381,9 +529,10 @@ impl Ws { } /// Fails the connection with our close: C's `lws_close_reason()` and - /// `LWS_HPI_RET_PLEASE_CLOSE_ME`. + /// `LWS_HPI_RET_PLEASE_CLOSE_ME`. The caller takes all of its input, + /// the rest of the read, which C drops. fn refuse<'a>(&mut self, code: u16, reason: &[u8]) -> Rx<'a> { - self.parse = Parse::Stopped; + self.parse = Parse::Refused; if matches!(self.closing, Closing::None) { self.closing = Closing::WaitingToSend(Ctl::close(code, reason)); } @@ -406,6 +555,15 @@ impl Ws { event: None, }; } + Parse::Refused => { + if !input.is_empty() && matches!(self.closing, Closing::AwaitingAck) { + self.closing = Closing::Closed(Close::Release); + } + return Rx { + consumed: input.len(), + event: None, + }; + } Parse::Payload(f, left) => return self.payload(f, left, input, used), Parse::First | Parse::Len(_) | Parse::LenMore(..) | Parse::Mask(..) => {} } @@ -417,8 +575,9 @@ impl Ws { }; used = used.saturating_add(1); if let Some((code, reason)) = self.header(c) { + // as C, the rest of what was read goes unread let mut r = self.refuse(code, reason); - r.consumed = used; + r.consumed = input.len(); return r; } } @@ -429,6 +588,7 @@ impl Ws { match self.parse { Parse::First => { let fin = c & 0x80 != 0; + let side = self.role.side(); let op = match c & 0x0f { 0 => Op::Continuation, 1 => Op::Text, @@ -436,20 +596,17 @@ impl Ws { 8 => Op::Close, 9 => Op::Ping, 10 => Op::Pong, - _ => { - if c & 0x08 != 0 && !fin { - return Some((1002, b"frag ctl")); - } - return Some((1002, b"bad opc")); + // C's server calls a reserved control opcode without + // FIN fragmented; its client, a bad opcode + _ if side == Side::Server && c & 0x08 != 0 && !fin => { + return Some((1002, b"frag ctl")); } + _ => return Some((1002, b"bad opc")), }; - if op.control() && !fin { - return Some((1002, b"frag ctl")); + if let Some(refused) = Self::first_byte_order(side, op, fin, c, self.msg) { + return Some(refused); } match (op, self.msg) { - (Op::Text | Op::Binary, Msg::Open { .. }) | (Op::Continuation, Msg::Idle) => { - return Some((1002, b"bad cont")); - } (Op::Text, Msg::Idle) => { self.utf8 = Utf8Validator::new(); self.msg = Msg::Open { @@ -463,12 +620,11 @@ impl Ws { given: Given::Nothing, }; } - (Op::Continuation, Msg::Open { .. }) + // refused above, or nothing to do + (Op::Text | Op::Binary, Msg::Open { .. }) + | (Op::Continuation, Msg::Idle | Msg::Open { .. }) | (Op::Close | Op::Ping | Op::Pong, Msg::Idle | Msg::Open { .. }) => {} } - if c & 0x70 != 0 { - return Some((1002, b"rsv bits")); - } self.parse = Parse::Len(Frame { op, fin, @@ -479,8 +635,10 @@ impl Ws { } Parse::Len(mut f) => { f.masked = c & 0x80 != 0; - if !f.masked { - return Some((1002, b"client unmasked")); + match (self.role.side(), f.masked) { + (Side::Server, false) => return Some((1002, b"client unmasked")), + (Side::Client, true) => return Some((1002, b"srv mask")), + (Side::Server, true) | (Side::Client, false) => {} } match c & 0x7f { 126 | 127 if f.op.control() => return Some((1002, b"ctl len")), @@ -488,7 +646,7 @@ impl Ws { 127 => self.parse = Parse::LenMore(f, 8), n => { f.len = u64::from(n); - self.parse = Parse::Mask(f, 4); + self.parse = Self::after_len(f); } } } @@ -503,7 +661,7 @@ impl Ws { } else if f.len > MAX_FRAME { return Some((1009, b"huge frame")); } else { - self.parse = Parse::Mask(f, 4); + self.parse = Self::after_len(f); } } Parse::Mask(mut f, left) => { @@ -515,17 +673,74 @@ impl Ws { self.parse = if left > 0 { Parse::Mask(f, left) } else { - self.ctl = Ctl::new(); Parse::Payload(f, f.len) }; } - Parse::Payload(..) | Parse::Stopped => {} + Parse::Payload(..) | Parse::Stopped | Parse::Refused => {} + } + if matches!(self.parse, Parse::Payload(..)) { + self.ctl = Ctl::new(); } None } + /// After the length: the mask, if the frame has one, else the payload. + const fn after_len(f: Frame) -> Parse { + if f.masked { + Parse::Mask(f, 4) + } else { + Parse::Payload(f, f.len) + } + } + + /// The first byte's checks after its opcode, each side's in its C + /// parser's order: the server's (`lws_ws_rx_sm()`) fragmented control, + /// continuation, then RSV; the client's (`lws_ws_client_rx_sm()`) + /// continuation, RSV, a message begun while one is open ("bad fin"), + /// then fragmented control. + const fn first_byte_order( + side: Side, + op: Op, + fin: bool, + c: u8, + msg: Msg, + ) -> Option<(u16, &'static [u8])> { + let frag_ctl = op.control() && !fin; + let rsv = c & 0x70 != 0; + let open = matches!(msg, Msg::Open { .. }); + let stray_cont = matches!(op, Op::Continuation) && !open; + let new_in_open = matches!(op, Op::Text | Op::Binary) && open; + match side { + Side::Server => { + if frag_ctl { + Some((1002, b"frag ctl")) + } else if stray_cont || new_in_open { + Some((1002, b"bad cont")) + } else if rsv { + Some((1002, b"rsv bits")) + } else { + None + } + } + Side::Client => { + if stray_cont { + Some((1002, b"bad cont")) + } else if rsv { + Some((1002, b"rsv bits")) + } else if new_in_open { + Some((1002, b"bad fin")) + } else if frag_ctl { + Some((1002, b"frag ctl")) + } else { + None + } + } + } + } + /// The payload of `f`, `left` of it still to come. fn payload<'a>(&'a mut self, f: Frame, left: u64, input: &'a mut [u8], used: usize) -> Rx<'a> { + let all = input.len(); let rest = input.get_mut(used..).unwrap_or_default(); let n = usize::try_from(left).unwrap_or(usize::MAX).min(rest.len()); let at = f.len.saturating_sub(left); @@ -575,12 +790,12 @@ impl Ws { if kind == Kind::Text { if self.utf8.feed(piece).is_err() { let mut r = self.refuse(1007, b"bad utf8"); - r.consumed = consumed; + r.consumed = all; return r; } if last && !self.utf8.at_boundary() { let mut r = self.refuse(1007, b"partial utf8"); - r.consumed = consumed; + r.consumed = all; return r; } } @@ -644,11 +859,13 @@ impl Ws { self.ctl.buf.first().copied().unwrap_or(0), self.ctl.buf.get(1).copied().unwrap_or(0), ]); - // a code no peer may send is answered as a protocol error - if code < 1000 - || matches!(code, 1004..=1006 | 1012..=1015) - || (1016..3000).contains(&code) - { + // a code no peer may send is answered as a protocol + // error; C's client takes 1012 to 1015 from a server + let reserved = match self.role.side() { + Side::Server => matches!(code, 1004..=1006 | 1012..=1015), + Side::Client => matches!(code, 1004..=1006), + }; + if code < 1000 || reserved || (1016..3000).contains(&code) { if let Some(b) = self.ctl.buf.get_mut(..2) { b.copy_from_slice(&1002u16.to_be_bytes()); } @@ -665,10 +882,14 @@ impl Ws { /// Commits a whole message, `len` bytes, whose payload [`Ws::tx`] pulls: /// C's `lws_write()` of a final frame. /// + /// A client's frame is masked with a mask drawn now, as C draws it in + /// `lws_write()`. + /// /// # Errors /// /// [`SendError::Busy`] while a frame is still going, or the connection - /// is closing. + /// is closing; [`SendError::NoMask`] if a client's random source has no + /// mask to give, which fails the connection. pub fn send(&mut self, kind: Kind, len: u64) -> Result<(), SendError> { if self.app != App::Idle || self.out.pending() || !matches!(self.closing, Closing::None) { return Err(SendError::Busy); @@ -677,11 +898,39 @@ impl Ws { Kind::Text => Op::Text, Kind::Binary => Op::Binary, }; - frame_header(op, len, &mut self.out); - self.app = App::Sending { owed: len }; + let Ok(mask) = self.role.next_mask() else { + self.fail(); + return Err(SendError::NoMask); + }; + frame_header(op, len, mask, &mut self.out); + self.app = App::Sending { + owed: len, + mask, + at: 0, + }; Ok(()) } + /// A client's random source failed it: nothing more is read or + /// written, and it asks to be released. + const fn fail(&mut self) { + self.parse = Parse::Stopped; + self.pong = None; + self.app = App::Idle; + self.closing = Closing::Closed(Close::Release); + } + + /// A control frame into what is in flight, masked as this end masks; + /// `false` if the mask could not be drawn, which fails the connection. + fn queue_control(&mut self, op: Op, c: &Ctl) -> bool { + let Ok(mask) = self.role.next_mask() else { + self.fail(); + return false; + }; + control_frame(op, c, mask, &mut self.out); + true + } + /// The application is done: the connection closes once what it sent /// has gone, without a close frame, as C's /// `lws_raw_transaction_completed()`. @@ -705,15 +954,24 @@ impl Ws { written = written.saturating_add(self.out.drain(room)); continue; } - if let App::Sending { owed } = self.app { + if let App::Sending { owed, mask, at } = self.app { let cap = usize::try_from(owed).unwrap_or(usize::MAX).min(room.len()); - let n = src.fill(room.get_mut(..cap).unwrap_or_default()).min(cap); + let piece = room.get_mut(..cap).unwrap_or_default(); + let n = src.fill(piece).min(cap); + if let (Some(m), Some(p)) = (mask, piece.get_mut(..n)) { + apply_mask(p, m, at); + } written = written.saturating_add(n); - let owed = owed.saturating_sub(u64::try_from(n).unwrap_or(owed)); + let n = u64::try_from(n).unwrap_or(owed); + let owed = owed.saturating_sub(n); self.app = if owed == 0 { App::Idle } else { - App::Sending { owed } + App::Sending { + owed, + mask, + at: at.wrapping_add(n), + } }; if owed > 0 { // the rest of the payload is not here yet @@ -723,7 +981,9 @@ impl Ws { } match self.closing { Closing::WaitingToSend(c) => { - control_frame(Op::Close, &c, &mut self.out); + if !self.queue_control(Op::Close, &c) { + return written; + } self.closing = Closing::AwaitingAck; continue; } @@ -742,7 +1002,9 @@ impl Ws { if let Some(p) = self.pong.take() { match self.closing { Closing::None | Closing::Returned(_) => { - control_frame(Op::Pong, &p, &mut self.out); + if !self.queue_control(Op::Pong, &p) { + return written; + } } Closing::WaitingToSend(_) | Closing::AwaitingAck @@ -752,7 +1014,9 @@ impl Ws { continue; } if let Closing::Returned(c) = self.closing { - control_frame(Op::Close, &c, &mut self.out); + if !self.queue_control(Op::Close, &c) { + return written; + } self.closing = Closing::Closed(Close::Shutdown); continue; } @@ -854,11 +1118,103 @@ mod tests { (0x1_0000, b"\x82\x7f\0\0\0\0\0\x01\0\0"), ] { let mut out = Out::new(); - frame_header(Op::Binary, len, &mut out); + frame_header(Op::Binary, len, None, &mut out); assert_eq!(&out.buf[..out.len], want, "{len}"); } } + /// Masks of zero, so a client's frames read plainly. + #[derive(Debug)] + struct Zeros; + impl Random for Zeros { + fn fill(&mut self, buf: &mut [u8]) -> Result<(), Unavailable> { + buf.fill(0); + Ok(()) + } + } + + /// A source with nothing to give. + #[derive(Debug)] + struct Dry; + impl Random for Dry { + fn fill(&mut self, _: &mut [u8]) -> Result<(), Unavailable> { + Err(Unavailable) + } + } + + /// What a client writes after taking `frames`. + fn client_answers(frames: &[u8]) -> ([u8; 64], usize) { + let mut ws = Ws::client(Zeros); + let mut input = [0u8; 16]; + let input = &mut input[..frames.len()]; + input.copy_from_slice(frames); + let mut at = 0; + while at < input.len() { + let rx = ws.rx(&mut input[at..]); + if rx.consumed == 0 { + break; + } + at = at.checked_add(rx.consumed).unwrap(); + } + let mut out = [0u8; 64]; + let n = ws.tx(&mut out, &mut Nothing); + (out, n) + } + + #[test] + fn a_clients_refusals_are_cs_client_parsers() { + for (frames, close) in [ + (&b"\x83\x00"[..], &b"\x88\x89\0\0\0\0\x03\xeabad opc"[..]), + // the server calls this one "frag ctl" + (b"\x0b\x00", b"\x88\x89\0\0\0\0\x03\xeabad opc"), + (b"\x80\x00", b"\x88\x8a\0\0\0\0\x03\xeabad cont"), + (b"\xc1\x00", b"\x88\x8a\0\0\0\0\x03\xearsv bits"), + (b"\x01\x00\x81\x00", b"\x88\x89\0\0\0\0\x03\xeabad fin"), + (b"\x09\x00", b"\x88\x8a\0\0\0\0\x03\xeafrag ctl"), + (b"\x81\x80", b"\x88\x8a\0\0\0\0\x03\xeasrv mask"), + (b"\x89\x7e", b"\x88\x89\0\0\0\0\x03\xeactl len"), + (b"\x82\x7f\x80", b"\x88\x89\0\0\0\0\x03\xeabad len"), + ] { + let (out, n) = client_answers(frames); + assert_eq!(&out[..n], close, "{}", frames.escape_ascii()); + } + } + + #[test] + fn a_client_takes_1012_to_1015_from_a_server() { + let (client, n) = client_answers(b"\x88\x02\x03\xf4"); + assert_eq!(&client[..n], b"\x88\x82\0\0\0\0\x03\xf4"); + // a server makes it 1002 + let (server, m) = answers(b"\x88\x82\0\0\0\0\x03\xf4"); + assert_eq!(&server[..m], b"\x88\x02\x03\xea"); + } + + #[test] + fn after_our_close_has_gone_anything_ends_it() { + let mut ws = Ws::client(Zeros); + let mut bad = *b"\xc1\x05Hello"; + assert_eq!(ws.rx(&mut bad).consumed, bad.len()); + // until our close has gone, what comes is dropped + let mut more = *b"\x81\x00"; + assert_eq!(ws.rx(&mut more).consumed, 2); + assert_eq!(ws.close(), None); + let mut out = [0u8; 64]; + assert!(ws.tx(&mut out, &mut Nothing) > 0); + assert_eq!(ws.close(), None); + let mut ack = *b"\x88\x02\x03\xe8"; + assert_eq!(ws.rx(&mut ack).consumed, 4); + assert_eq!(ws.close(), Some(Close::Release)); + } + + #[test] + fn a_client_with_no_random_fails() { + let mut ws = Ws::client(Dry); + assert_eq!(ws.send(Kind::Text, 1), Err(SendError::NoMask)); + assert_eq!(ws.close(), Some(Close::Release)); + let mut out = [0u8; 8]; + assert_eq!(ws.tx(&mut out, &mut Nothing), 0); + } + #[test] fn a_second_ping_while_a_pong_is_owed_is_dropped() { let (out, n) = answers(b"\x89\x81\0\0\0\0a\x89\x81\0\0\0\0b"); diff --git a/crates/npro-ws/src/handshake.rs b/crates/npro-ws/src/handshake.rs index 70066ef..4f841d2 100644 --- a/crates/npro-ws/src/handshake.rs +++ b/crates/npro-ws/src/handshake.rs @@ -1,5 +1,7 @@ -//! A server's side of the ws handshake: C's `lws_process_ws_upgrade()` and -//! `handshake_0405()`. +//! The ws handshake, both sides. +//! +//! **A server's**, [`server`] and [`response_101`]: C's +//! `lws_process_ws_upgrade()` and `handshake_0405()`. //! //! C's checks, in C's order, each refusal C's status: an upgrade is a GET; //! its `Connection` names the token `upgrade`; it has a key, of less than @@ -7,8 +9,17 @@ //! (saying `sec-websocket-version: 13`) for another; and it asks for a //! subprotocol the server has, the first of its list that it has, or with //! no list, the server's default. +//! +//! **A client's**, [`ClientKey`]: C's `lws_generate_client_ws_handshake()` +//! and `lws_client_ws_upgrade()`. The key is 16 random bytes; the request +//! asks for the upgrade with it, the subprotocols offered and version 13; +//! and the response must be a 101 with an accept, `Upgrade: websocket`, +//! `upgrade` among its `Connection` tokens, a subprotocol, if it names one, +//! that was offered, no extension (npro-ws has none yet), and the accept +//! the key makes. use npro_core::base64; +use npro_core::random::{Random, Unavailable}; use npro_core::sha1::Sha1; use npro_h1::table::HeaderTable; use npro_h1::token::Token; @@ -225,11 +236,7 @@ pub fn server<S: AsRef<[u8]> + AsMut<[u8]>>( (found.ok_or(Refusal::NoProtocol)?, true) }; - let mut h = Sha1::new(); - h.update(key); - h.update(GUID); - let mut accept = [0u8; ACCEPT_LEN]; - base64::encode(&h.finish(), &mut accept).map_err(|_| Refusal::KeyOrHost)?; + let accept = accept_of(key).ok_or(Refusal::KeyOrHost)?; Ok(Accepted { protocol, named, @@ -237,6 +244,16 @@ pub fn server<S: AsRef<[u8]> + AsMut<[u8]>>( }) } +/// The accept a key makes: base64 of the SHA-1 of the key and the GUID. +fn accept_of(key: &[u8]) -> Option<[u8; ACCEPT_LEN]> { + let mut h = Sha1::new(); + h.update(key); + h.update(GUID); + let mut accept = [0u8; ACCEPT_LEN]; + base64::encode(&h.finish(), &mut accept).ok()?; + Some(accept) +} + /// The most a 101 C writes may have here. pub const MAX_101: usize = 256; @@ -265,3 +282,352 @@ pub fn response_101(a: &Accepted, name: &[u8], out: &mut [u8]) -> Option<usize> put(b"\r\n\r\n")?; Some(at) } + +/// The length of a client's key: base64 of 16 bytes. +pub const KEY_LEN: usize = 24; + +/// The most the lines [`ClientKey::request_lines`] writes may have, but +/// for the subprotocols offered. +pub const MAX_REQUEST_LINES: usize = 160; + +/// Why a client fails the server's response to its upgrade: each is C's +/// `CLIENT_CONNECTION_ERROR` reason. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ClientRefusal { + /// The status is not 101: "HS: ws upgrade response not 101". + NotSwitching, + /// There is no accept: "HS: ACCEPT missing". + NoAccept, + /// There is no `Upgrade`: "HS: UPGRADE missing". + NoUpgrade, + /// The `Upgrade` is not `websocket`: "HS: Upgrade to something other + /// than websocket". + NotWebsocket, + /// No `upgrade` among the `Connection` tokens: "HS: UPGRADE + /// malformed". + Connection, + /// A subprotocol that was not offered: "HS: PROTOCOL malformed". + Protocol, + /// An extension, of which there are none: "HS: EXT: unknown ext". + Extension, + /// The accept is not the key's: "HS: Accept hash wrong". + Accept, +} + +impl core::fmt::Display for ClientRefusal { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.write_str(match self { + Self::NotSwitching => "HS: ws upgrade response not 101", + Self::NoAccept => "HS: ACCEPT missing", + Self::NoUpgrade => "HS: UPGRADE missing", + Self::NotWebsocket => "HS: Upgrade to something other than websocket", + Self::Connection => "HS: UPGRADE malformed", + Self::Protocol => "HS: PROTOCOL malformed", + Self::Extension => "HS: EXT: unknown ext", + Self::Accept => "HS: Accept hash wrong", + }) + } +} + +impl core::error::Error for ClientRefusal {} + +/// A client's key, and the accept it expects for it. +/// +/// ``` +/// use npro_core::random::SeededRandom; +/// use npro_h1::client::{Client, Connection, Event, Request, Scheme}; +/// use npro_ws::handshake::{ClientKey, MAX_REQUEST_LINES}; +/// +/// // C's seeded random, as its ws-client transcript has it +/// let key = ClientKey::new(&mut SeededRandom::new(1))?; +/// assert_eq!(key.key(), b"OvomtQpKCWUnZW7tMR6Gqw=="); +/// +/// let mut lines = [0u8; MAX_REQUEST_LINES + 32]; +/// let n = key.request_lines(Some(b"echo"), &mut lines).unwrap(); +/// let mut c = Client::new([0u8; 1024], Request { +/// method: b"GET", +/// path: b"/echo", +/// host: Some(b"sansio"), +/// origin: None, +/// scheme: Scheme::Http, +/// no_cache: false, +/// connection: Connection::Upgrade(&lines[..n]), +/// })?; +/// let mut out = [0u8; 512]; +/// let n = c.tx(&mut out); +/// assert!(out[..n].starts_with(b"GET /echo HTTP/1.1\r\nHost: sansio\r\nUpgrade: websocket\r\n")); +/// +/// let rx = c.rx(b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\n\ +/// Connection: Upgrade\r\nSec-WebSocket-Protocol: echo\r\n\ +/// Sec-WebSocket-Accept: rSsJf/ZKQdiul0BGIJ6uQGawdU8=\r\n\r\n")?; +/// assert_eq!(rx.event, Some(Event::Response)); +/// let chosen = key.check(c.status(), c.response(), Some(b"echo"))?; +/// assert_eq!(chosen, Some(&b"echo"[..])); +/// # Ok::<(), Box<dyn core::error::Error>>(()) +/// ``` +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ClientKey { + key: [u8; KEY_LEN], + accept: [u8; ACCEPT_LEN], +} + +impl ClientKey { + /// A key of 16 bytes drawn from `random`, in one draw, as C draws it. + /// + /// # Errors + /// + /// [`Unavailable`] if the source has none to give: C fails the + /// connection. + pub fn new(random: &mut dyn Random) -> Result<Self, Unavailable> { + let mut raw = [0u8; 16]; + random.fill(&mut raw)?; + let mut key = [0u8; KEY_LEN]; + // 16 bytes are always 24 of base64, whose accept is always made + base64::encode(&raw, &mut key).map_err(|_| Unavailable)?; + let accept = accept_of(&key).ok_or(Unavailable)?; + Ok(Self { key, accept }) + } + + /// The `Sec-WebSocket-Key` value. + #[must_use] + pub const fn key(&self) -> &[u8; KEY_LEN] { + &self.key + } + + /// The lines asking for the upgrade, in C's order, offering + /// `protocols`, a comma separated list, if any: for the request's + /// [`npro_h1::client::Connection::Upgrade`]. Written into `out`, + /// returning how much of it. + /// + /// `None` if `out` is too small, or `protocols` would break the line: + /// it may not hold a CR, LF or NUL. + #[must_use] + pub fn request_lines(&self, protocols: Option<&[u8]>, out: &mut [u8]) -> Option<usize> { + if protocols.is_some_and(|p| p.iter().any(|c| matches!(c, b'\r' | b'\n' | 0))) { + return None; + } + let mut at = 0usize; + let mut put = |b: &[u8]| -> Option<()> { + let end = at.checked_add(b.len())?; + out.get_mut(at..end)?.copy_from_slice(b); + at = end; + Some(()) + }; + put(b"Upgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: ")?; + put(&self.key)?; + put(b"\r\n")?; + if let Some(p) = protocols { + put(b"Sec-WebSocket-Protocol: ")?; + put(p)?; + put(b"\r\n")?; + } + put(b"Sec-WebSocket-Version: 13\r\n")?; + Some(at) + } + + /// Checks the server's final response, its status `status` and its + /// headers `t`, to a request that offered `offered`: C's checks, in C's + /// order. Returns the subprotocol the server named, if it named one. + /// + /// # Errors + /// + /// The [`ClientRefusal`]: the connection fails. + pub fn check<'t, S: AsRef<[u8]> + AsMut<[u8]>>( + &self, + status: Option<u16>, + t: &'t HeaderTable<S>, + offered: Option<&[u8]>, + ) -> Result<Option<&'t [u8]>, ClientRefusal> { + if status != Some(101) { + return Err(ClientRefusal::NotSwitching); + } + if t.total_len(Token::WsAccept) == 0 { + return Err(ClientRefusal::NoAccept); + } + let upgrade = t.first(Token::Upgrade).ok_or(ClientRefusal::NoUpgrade)?; + if !upgrade.eq_ignore_ascii_case(b"websocket") { + return Err(ClientRefusal::NotWebsocket); + } + + // C takes the list only if it fits its 64 byte buffer + let mut buf = [0u8; 64]; + let conn = t + .copy(Token::Connection, buf.get_mut(..63).unwrap_or_default()) + .ok() + .filter(|n| *n > 0) + .and_then(|n| buf.get(..n)) + .ok_or(ClientRefusal::Connection)?; + let mut upgrade_token = false; + for tok in tokens(conn) { + match tok { + Some(name) if name.eq_ignore_ascii_case(b"upgrade") => { + upgrade_token = true; + break; + } + Some(_) => {} + None => return Err(ClientRefusal::Connection), + } + } + if !upgrade_token { + return Err(ClientRefusal::Connection); + } + + let chosen = if t.total_len(Token::WsProtocol) == 0 { + None + } else { + let name = t.first(Token::WsProtocol).unwrap_or_default(); + let was_offered = offered.is_some_and(|list| { + list.split(|c| *c == b',') + .map(|e| { + let start = e.iter().position(|c| *c != b' ').unwrap_or(e.len()); + e.get(start..).unwrap_or_default() + }) + .any(|e| e == name) + }); + if !was_offered { + return Err(ClientRefusal::Protocol); + } + Some(name) + }; + + if t.total_len(Token::WsExtensions) > 0 { + return Err(ClientRefusal::Extension); + } + if t.first(Token::WsAccept) != Some(self.accept.as_slice()) { + return Err(ClientRefusal::Accept); + } + Ok(chosen) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use npro_core::random::SeededRandom; + use npro_h1::client::{Client, Connection, Request, Scheme}; + + /// The head of a 101 with the accept of seed 1's key, as C's ws-client + /// transcript has it. + macro_rules! ok_101 { + ($rest:literal) => { + concat!( + "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\n", + "Connection: Upgrade\r\nSec-WebSocket-Accept: rSsJf/ZKQdiul0BGIJ6uQGawdU8=\r\n", + $rest, + "\r\n" + ) + }; + } + + /// What became of a response. + #[derive(Debug, PartialEq, Eq)] + enum Verdict { + /// Taken, naming a protocol this long, or none. + Took(Option<usize>), + Refused(ClientRefusal), + } + + /// What the key of seed 1, having offered `echo, chat`, makes of + /// `response`. + fn verdict(response: &str) -> Verdict { + let key = ClientKey::new(&mut SeededRandom::new(1)).unwrap(); + let mut lines = [0u8; MAX_REQUEST_LINES]; + let n = key.request_lines(Some(b"echo, chat"), &mut lines).unwrap(); + let mut c = Client::new( + [0u8; 1024], + Request { + method: b"GET", + path: b"/", + host: None, + origin: None, + scheme: Scheme::Http, + no_cache: false, + connection: Connection::Upgrade(&lines[..n]), + }, + ) + .unwrap(); + let mut out = [0u8; 512]; + let _ = c.tx(&mut out); + let _ = c.rx(response.as_bytes()).unwrap(); + assert!(c.is_upgraded(), "{response}"); + match key.check(c.status(), c.response(), Some(b"echo, chat")) { + Ok(p) => Verdict::Took(p.map(<[u8]>::len)), + Err(r) => Verdict::Refused(r), + } + } + + #[test] + fn a_good_101_names_an_offered_protocol_or_none() { + assert_eq!(verdict(ok_101!("")), Verdict::Took(None)); + assert_eq!( + verdict(ok_101!("Sec-WebSocket-Protocol: chat\r\n")), + Verdict::Took(Some(4)) + ); + assert_eq!( + verdict(ok_101!("Sec-WebSocket-Protocol: chit\r\n")), + Verdict::Refused(ClientRefusal::Protocol) + ); + assert_eq!( + verdict(ok_101!("Sec-WebSocket-Extensions: x\r\n")), + Verdict::Refused(ClientRefusal::Extension) + ); + } + + #[test] + fn a_bad_response_is_refused_in_cs_order() { + for (response, refusal) in [ + ( + "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n", + ClientRefusal::NotSwitching, + ), + ( + "HTTP/1.1 101 S\r\nUpgrade: websocket\r\n\r\n", + ClientRefusal::NoAccept, + ), + ( + "HTTP/1.1 101 S\r\nSec-WebSocket-Accept: x\r\n\r\n", + ClientRefusal::NoUpgrade, + ), + ( + "HTTP/1.1 101 S\r\nSec-WebSocket-Accept: x\r\nUpgrade: h2c\r\n\r\n", + ClientRefusal::NotWebsocket, + ), + ( + "HTTP/1.1 101 S\r\nSec-WebSocket-Accept: x\r\nUpgrade: WebSocket\r\n\r\n", + ClientRefusal::Connection, + ), + ( + "HTTP/1.1 101 S\r\nSec-WebSocket-Accept: x\r\nUpgrade: websocket\r\n\ + Connection: keep-alive\r\n\r\n", + ClientRefusal::Connection, + ), + // C takes a token that starts "upgrade": npro takes only it + ( + "HTTP/1.1 101 S\r\nSec-WebSocket-Accept: x\r\nUpgrade: websocket\r\n\ + Connection: up\r\n\r\n", + ClientRefusal::Connection, + ), + ( + "HTTP/1.1 101 S\r\nSec-WebSocket-Accept: x\r\nUpgrade: websocket\r\n\ + Connection: Upgrade\r\n\r\n", + ClientRefusal::Accept, + ), + ] { + assert_eq!(verdict(response), Verdict::Refused(refusal), "{response}"); + } + } + + #[test] + fn the_request_lines_refuse_a_protocol_list_that_breaks_the_line() { + let key = ClientKey::new(&mut SeededRandom::new(1)).unwrap(); + let mut out = [0u8; MAX_REQUEST_LINES]; + assert_eq!(key.request_lines(Some(b"a\r\nX: y"), &mut out), None); + assert_eq!(key.request_lines(None, &mut out[..10]), None); + let n = key.request_lines(None, &mut out).unwrap(); + assert_eq!( + &out[..n], + b"Upgrade: websocket\r\nConnection: Upgrade\r\n\ + Sec-WebSocket-Key: OvomtQpKCWUnZW7tMR6Gqw==\r\nSec-WebSocket-Version: 13\r\n" + ); + } +} diff --git a/crates/npro-ws/src/lib.rs b/crates/npro-ws/src/lib.rs index 483678d..0ddd2e0 100644 --- a/crates/npro-ws/src/lib.rs +++ b/crates/npro-ws/src/lib.rs @@ -2,10 +2,11 @@ //! //! The port of C libwebsockets' ws role (`lib/sansio/ws`): //! -//! - [`handshake`]: a server's checks of an upgrade request, and its 101; -//! - [`conn`]: a ws connection, its frames in and out, and its close. -//! -//! The client's handshake comes next, in phase 1e of the port plan. +//! - [`handshake`]: a server's checks of an upgrade request and its 101, +//! and a client's key, its request's upgrade lines and its checks of the +//! server's response; +//! - [`conn`]: a ws connection, either end, its frames in and out, and its +//! close. #![no_std] #![forbid(unsafe_code)]
Page fetched 0s ago, creation time: 5ms (vhost etag hits: 0%, cache hits: 0%)