| Author | Andy Green <andy@warmcat.com> 2026-10-05 16:38 UTC | | Committer | Andy Green <andy@warmcat.com> 2026-10-05 19:18 UTC | | Tree | 1f263793b76a87c2fe6df5943549450f0ac46a74 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)]
|