From 49538ccc83bcb0203f6d2efeff61f2ae66fc0ebd Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 12 Jun 2026 05:38:14 +0000 Subject: [PATCH] =?UTF-8?q?feat(ws):=20RFC=206455=20opening=20handshake=20?= =?UTF-8?q?=E2=80=94=20Conn::upgrade,=20101=20short-circuit=20(v0.4=20chun?= =?UTF-8?q?k=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design (as agreed in the v0.4 pass): - Dep #3 spent: sha1_smol 1.0.1 (zero transitive deps) instead of a vendored SHA-1 — user call: no hand-rolled crypto. Consequence noted in ROADMAP: the v0.6 wire-format JSON question now costs dep #4. - Base64 stays in-tree but ENCODE-ONLY (RFC 4648 vectors tested): it is an encoding, not crypto, and the decode direction (where parsing bugs live) is deliberately not implemented. - src/ws/{mod,handshake}.rs: validate() implements §4.2.1 (GET, 1.1, Host, upgrade/connection token lists case-insensitively, key shape = base64 of exactly 16 bytes, version 13). Version mismatch -> 426 + sec-websocket-version: 13 (§4.2.2); everything else -> 400. Rejected upgrades stay plain HTTP with keep-alive intact (tested: 400 then 101 on the same connection). - Conn grows pub upgrade: Option (opaque marker; chunk 3 turns it into the duplex/handler handoff payload — shape deliberately uncommitted while the handler API is open). Conn::upgrade(self) is the commit point: 101 + accept key + marker + halt, or the rejection. - conn_actor: upgrade marker + status 101 -> write head, leave the HTTP loop. Until chunk 3 that drops the fd (clients see 101 then EOF); status check is defensive against a post-handler plug clobbering 101. - serialise_response: 1xx no longer get content-length injected (RFC 7230 §3.3.2). 204 left alone on purpose — separate conversation. Accept-key path pinned to the §1.3 worked example end-to-end (unit + wire). Suite: 34 unit + 33 integration + 2 doc. --- Cargo.toml | 1 + src/conn.rs | 47 ++++++++ src/conn_actor.rs | 14 +++ src/lib.rs | 1 + src/parser.rs | 5 +- src/ws/handshake.rs | 250 +++++++++++++++++++++++++++++++++++++++++++ src/ws/mod.rs | 26 +++++ tests/integration.rs | 92 ++++++++++++++++ 8 files changed, 435 insertions(+), 1 deletion(-) create mode 100644 src/ws/handshake.rs create mode 100644 src/ws/mod.rs diff --git a/Cargo.toml b/Cargo.toml index d557371..1a029c9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ description = "Cowboy/bandit-style HTTP library for the smarm actor runtime" smarm = { path = "../smarm" } httparse = "1.9" libc = "0.2" +sha1_smol = "1" [features] smarm-trace = ["smarm/smarm-trace"] diff --git a/src/conn.rs b/src/conn.rs index 3bedc32..4a91e65 100644 --- a/src/conn.rs +++ b/src/conn.rs @@ -308,6 +308,11 @@ pub struct Conn { pub params: Params, pub assigns: Assigns, pub halted: bool, + + /// Set by [`Conn::upgrade`] on an accepted WebSocket handshake; the + /// connection actor sees it (with status 101) and leaves the HTTP + /// loop after writing the response. `None` for plain HTTP. + pub upgrade: Option, } impl Conn { @@ -329,6 +334,8 @@ impl Conn { params: Params::new(), assigns: Assigns::new(), halted: false, + + upgrade: None, } } @@ -358,6 +365,46 @@ impl Conn { self.halted = true; self } + + /// Accept a WebSocket upgrade on this request (RFC 6455 §4.2). + /// + /// On a valid handshake: status `101`, the `upgrade`/`connection`/ + /// `sec-websocket-accept` response headers, the [`WsUpgrade`] marker + /// the connection actor acts on, and the pipeline is halted. + /// + /// On an invalid handshake: the appropriate rejection (`400`, or + /// `426` + `sec-websocket-version: 13` for a version mismatch), + /// empty body, pipeline halted; the connection stays plain HTTP with + /// normal keep-alive semantics. Pre-check with + /// [`ws::handshake::is_upgrade_request`](crate::ws::handshake::is_upgrade_request) + /// to branch before committing. + /// + /// [`WsUpgrade`]: crate::ws::WsUpgrade + pub fn upgrade(mut self) -> Self { + use crate::ws::{handshake, Rejection}; + match handshake::validate(&self) { + Ok(accept) => { + self.upgrade = Some(crate::ws::WsUpgrade { _priv: () }); + self.put_status(101) + .put_header("upgrade", "websocket") + .put_header("connection", "Upgrade") + .put_header("sec-websocket-accept", accept) + .put_body(RespBody::Empty) + .halt() + } + Err(rej) => { + let c = self + .put_status(rej.status()) + .put_body(RespBody::Empty) + .halt(); + if rej == Rejection::UnsupportedVersion { + c.put_header("sec-websocket-version", "13") + } else { + c + } + } + } + } } impl Default for Conn { diff --git a/src/conn_actor.rs b/src/conn_actor.rs index cb521d0..daa2451 100644 --- a/src/conn_actor.rs +++ b/src/conn_actor.rs @@ -231,6 +231,20 @@ pub fn run_connection( .put_body(RespBody::Empty); } + // ----- 3.5 WebSocket upgrade short-circuit. ----- + // An accepted handshake (marker + 101) ends HTTP on this socket: + // write the 101 head and leave the request loop. Chunk 3 (duplex + // wiring) takes the socket over right here; until then leaving + // the loop closes it via OwnedFd::drop — the 101 is still the + // honest, testable wire artefact. The status check is defensive: + // a post-handler plug that clobbered the 101 forfeits the + // upgrade and falls through to plain HTTP. + if response_conn.upgrade.is_some() && response_conn.status == Some(101) { + let head = parser::serialise_response(&response_conn, true); + let _ = write_all(raw, &head, Instant::now() + limits.write_timeout); + return; + } + // ----- 4. Write the response. ----- // A Stream body on HTTP/1.0 has no chunked framing: the body is // delimited by EOF, so keep-alive is forced off for this response diff --git a/src/lib.rs b/src/lib.rs index 17acbc0..551a2a7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -27,6 +27,7 @@ pub mod conn_actor; pub mod conn_registry; pub mod serve; pub mod sse; +pub mod ws; // Re-exports — what most users want at the crate root. pub use conn::{Assigns, Body, Conn, HeaderMap, HttpVersion, Method, Params, RespBody, StreamBody}; diff --git a/src/parser.rs b/src/parser.rs index 8ba83ea..3925d4a 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -243,7 +243,10 @@ pub fn serialise_response(conn: &Conn, keep_alive: bool) -> Vec { if conn.version == HttpVersion::Http11 { out.extend_from_slice(b"transfer-encoding: chunked\r\n"); } - } else if !wrote_content_length { + } else if !wrote_content_length && !(100..200).contains(&status) { + // 1xx responses have no body by definition (RFC 7230 §3.3.2) — + // injecting `content-length: 0` on the 101 upgrade response is a + // protocol violation some clients reject. out.extend_from_slice(b"content-length: "); out.extend_from_slice(body_len.to_string().as_bytes()); out.extend_from_slice(b"\r\n"); diff --git a/src/ws/handshake.rs b/src/ws/handshake.rs new file mode 100644 index 0000000..d47be44 --- /dev/null +++ b/src/ws/handshake.rs @@ -0,0 +1,250 @@ +//! RFC 6455 §4.2 — the opening handshake, server side. +//! +//! Pure functions over an already-parsed request; no io. The connection +//! actor owns the wire. + +use crate::conn::{Conn, HttpVersion, Method}; + +/// RFC 6455 §1.3 — the fixed GUID appended to the client key. +const WS_GUID: &str = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; + +/// Why a handshake was rejected. Maps onto a response via +/// [`Rejection::status`]: everything is a `400` except a version +/// mismatch, which RFC 6455 §4.2.2 answers with `426` + a +/// `sec-websocket-version: 13` header so the client can retry. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Rejection { + /// Handshake requests must be GET (§4.2.1). + NotGet, + /// HTTP/1.1 or higher required (§4.2.1). + BadHttpVersion, + /// `Host` header missing (§4.2.1 item 2). + MissingHost, + /// `Upgrade` header missing or lacks the `websocket` token. + NotAnUpgrade, + /// `Connection` header missing or lacks the `upgrade` token. + ConnectionNotUpgrade, + /// `Sec-WebSocket-Key` missing or not base64 of exactly 16 bytes. + BadKey, + /// `Sec-WebSocket-Version` is not 13 → 426 Upgrade Required. + UnsupportedVersion, +} + +impl Rejection { + pub fn status(self) -> u16 { + match self { + Rejection::UnsupportedVersion => 426, + _ => 400, + } + } +} + +/// Cheap routing predicate: does this request *ask* for a WebSocket +/// upgrade? (Token checks only — full validation happens in +/// [`validate`].) Lets a handler branch without committing to the +/// handshake. +pub fn is_upgrade_request(conn: &Conn) -> bool { + has_token(conn.headers.get("upgrade"), "websocket") + && has_token(conn.headers.get("connection"), "upgrade") +} + +/// Full §4.2.1 server-side validation. `Ok` carries the computed +/// `Sec-WebSocket-Accept` value. +pub fn validate(conn: &Conn) -> Result { + if conn.method != Method::Get { + return Err(Rejection::NotGet); + } + if conn.version != HttpVersion::Http11 { + return Err(Rejection::BadHttpVersion); + } + if conn.headers.get("host").is_none() { + return Err(Rejection::MissingHost); + } + if !has_token(conn.headers.get("upgrade"), "websocket") { + return Err(Rejection::NotAnUpgrade); + } + if !has_token(conn.headers.get("connection"), "upgrade") { + return Err(Rejection::ConnectionNotUpgrade); + } + match conn.headers.get("sec-websocket-version") { + Some(v) if v.trim() == "13" => {} + _ => return Err(Rejection::UnsupportedVersion), + } + let key = conn + .headers + .get("sec-websocket-key") + .map(str::trim) + .ok_or(Rejection::BadKey)?; + if !key_is_b64_16_bytes(key) { + return Err(Rejection::BadKey); + } + Ok(accept_key(key)) +} + +/// `base64(SHA1(key ++ GUID))` — §4.2.2 item 5.4. +pub fn accept_key(key: &str) -> String { + let mut h = sha1_smol::Sha1::new(); + h.update(key.as_bytes()); + h.update(WS_GUID.as_bytes()); + b64(&h.digest().bytes()) +} + +/// Case-insensitive token search in a comma-separated header value. +/// `Connection: keep-alive, Upgrade` must match token `upgrade`. +fn has_token(value: Option<&str>, token: &str) -> bool { + match value { + None => false, + Some(v) => v + .split(',') + .any(|t| t.trim().eq_ignore_ascii_case(token)), + } +} + +/// The client key must be base64 of exactly 16 bytes: 24 chars, last two +/// `==`, the rest in the standard alphabet (RFC 4648 §4). We never +/// decode it — §4.2.2 only concatenates the encoded form. +fn key_is_b64_16_bytes(key: &str) -> bool { + let b = key.as_bytes(); + b.len() == 24 + && b[22] == b'=' + && b[23] == b'=' + && b[..22] + .iter() + .all(|&c| c.is_ascii_alphanumeric() || c == b'+' || c == b'/') +} + +/// RFC 4648 base64 *encoding* (standard alphabet, padded). Encode-only +/// and not cryptographic — kept in-tree by design; do not grow a decoder +/// here without a design conversation (decoding is where the parsing +/// bugs live). +pub(crate) fn b64(input: &[u8]) -> String { + const ALPHA: &[u8; 64] = + b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut out = String::with_capacity(input.len().div_ceil(3) * 4); + for chunk in input.chunks(3) { + let b0 = chunk[0] as u32; + let b1 = *chunk.get(1).unwrap_or(&0) as u32; + let b2 = *chunk.get(2).unwrap_or(&0) as u32; + let n = (b0 << 16) | (b1 << 8) | b2; + out.push(ALPHA[(n >> 18) as usize & 63] as char); + out.push(ALPHA[(n >> 12) as usize & 63] as char); + out.push(if chunk.len() > 1 { ALPHA[(n >> 6) as usize & 63] as char } else { '=' }); + out.push(if chunk.len() > 2 { ALPHA[n as usize & 63] as char } else { '=' }); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::conn::HeaderMap; + + // ----- base64: RFC 4648 §10 test vectors. ----- + #[test] + fn b64_rfc4648_vectors() { + assert_eq!(b64(b""), ""); + assert_eq!(b64(b"f"), "Zg=="); + assert_eq!(b64(b"fo"), "Zm8="); + assert_eq!(b64(b"foo"), "Zm9v"); + assert_eq!(b64(b"foob"), "Zm9vYg=="); + assert_eq!(b64(b"fooba"), "Zm9vYmE="); + assert_eq!(b64(b"foobar"), "Zm9vYmFy"); + } + + // ----- accept key: the RFC 6455 §1.3 worked example. ----- + #[test] + fn accept_key_rfc6455_example() { + assert_eq!( + accept_key("dGhlIHNhbXBsZSBub25jZQ=="), + "s3pPLMBiTxaQ9kYGzzhZRbK+xOo=" + ); + } + + fn ws_conn() -> Conn { + let mut c = Conn::new(); + c.method = Method::Get; + c.version = HttpVersion::Http11; + let mut h = HeaderMap::new(); + h.append("host", "example.com"); + h.append("upgrade", "websocket"); + h.append("connection", "Upgrade"); + h.append("sec-websocket-key", "dGhlIHNhbXBsZSBub25jZQ=="); + h.append("sec-websocket-version", "13"); + c.headers = h; + c + } + + #[test] + fn validate_happy_path() { + assert_eq!( + validate(&ws_conn()).unwrap(), + "s3pPLMBiTxaQ9kYGzzhZRbK+xOo=" + ); + } + + #[test] + fn validate_rejects_post() { + let mut c = ws_conn(); + c.method = Method::Post; + assert_eq!(validate(&c), Err(Rejection::NotGet)); + } + + #[test] + fn validate_rejects_http10() { + let mut c = ws_conn(); + c.version = HttpVersion::Http10; + assert_eq!(validate(&c), Err(Rejection::BadHttpVersion)); + } + + #[test] + fn validate_requires_host() { + let mut c = ws_conn(); + let mut h = HeaderMap::new(); + for (n, v) in c.headers.iter() { + if n != "host" { + h.append(n, v); + } + } + c.headers = h; + assert_eq!(validate(&c), Err(Rejection::MissingHost)); + } + + #[test] + fn validate_connection_token_in_list() { + let mut c = ws_conn(); + c.headers.set("connection", "keep-alive, Upgrade"); + assert!(validate(&c).is_ok()); + } + + #[test] + fn validate_rejects_wrong_ws_version() { + let mut c = ws_conn(); + c.headers.set("sec-websocket-version", "8"); + assert_eq!(validate(&c), Err(Rejection::UnsupportedVersion)); + assert_eq!(Rejection::UnsupportedVersion.status(), 426); + } + + #[test] + fn validate_rejects_bad_keys() { + for bad in [ + "tooshort==", + "dGhlIHNhbXBsZSBub25jZQ=!", // bad padding char + "dGhlIHNhbXBsZSBub25jZSE=", // single pad = 17 bytes, not 16 + "dGhlIHNhbXBsZSBub25jZQQQ", // unpadded 24 chars (18 bytes) + "", + ] { + let mut c = ws_conn(); + c.headers.set("sec-websocket-key", bad); + assert_eq!(validate(&c), Err(Rejection::BadKey), "key: {bad:?}"); + } + } + + #[test] + fn is_upgrade_request_predicate() { + assert!(is_upgrade_request(&ws_conn())); + let mut c = ws_conn(); + c.headers.set("upgrade", "h2c"); + assert!(!is_upgrade_request(&c)); + assert!(!is_upgrade_request(&Conn::new())); + } +} diff --git a/src/ws/mod.rs b/src/ws/mod.rs new file mode 100644 index 0000000..d0eb6ee --- /dev/null +++ b/src/ws/mod.rs @@ -0,0 +1,26 @@ +//! WebSocket support (RFC 6455). +//! +//! v0.4 chunk 1: the upgrade handshake. A handler that wants to speak +//! WebSocket calls [`Conn::upgrade`](crate::Conn::upgrade); on a valid +//! handshake the connection actor writes the `101 Switching Protocols` +//! response and leaves the HTTP request loop. (Until the duplex wiring +//! lands — chunk 3 — leaving the loop closes the socket; the 101 itself +//! is correct and tested.) +//! +//! Dependency note: SHA-1 comes from `sha1_smol` (zero transitive deps), +//! spending urus dependency slot #3 — agreed in the v0.4 design pass over +//! vendoring. Base64 here is the 20-byte *encode* direction only and is +//! not cryptographic, so that stays in-tree (`handshake::b64`). + +pub mod handshake; + +pub use handshake::Rejection; + +/// Marker carried on a [`Conn`](crate::Conn) whose handler accepted a +/// WebSocket upgrade. Opaque: chunk 3 grows this into the handler/duplex +/// handoff payload, so nothing outside the crate should depend on its +/// shape. +#[derive(Debug)] +pub struct WsUpgrade { + pub(crate) _priv: (), +} diff --git a/tests/integration.rs b/tests/integration.rs index 0f6b2df..a04a6c7 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -967,3 +967,95 @@ fn sse_heartbeats_on_silence() { let beats = body.matches(": keep-alive").count(); assert!(beats >= 2, "expected >=2 heartbeats in 450ms at 100ms interval, got {beats}: {body:?}"); } + +// --------------------------------------------------------------------------- +// WebSocket handshake (v0.4 chunk 1). Duplex lands in chunk 3 — for now an +// accepted upgrade writes the 101 and the actor leaves the HTTP loop, which +// closes the socket; the tests assert the wire artefact, not ws traffic. +// --------------------------------------------------------------------------- + +fn ws_pipeline() -> Pipeline { + Pipeline::new().plug(Router::new().get("/ws", |c: Conn, _n: Next| c.upgrade())) +} + +#[test] +fn ws_handshake_101_with_accept_key() { + let port = spawn_server(ws_pipeline()); + let resp = send_request( + port, + b"GET /ws HTTP/1.1\r\nHost: x\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\ + Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\n\r\n", + ); + assert_eq!(http_status(&resp), 101); + let head = String::from_utf8_lossy(&resp).to_lowercase(); + assert!(head.contains("upgrade: websocket"), "{head}"); + assert!(head.contains("connection: upgrade"), "{head}"); + // RFC 6455 §1.3 worked example key -> known accept value. + assert!( + head.contains("sec-websocket-accept: s3pplmbitxaq9kygzzhzrbk+xoo="), + "{head}" + ); + // 1xx carries no body framing. + assert!(!head.contains("content-length"), "{head}"); + assert!(!head.contains("transfer-encoding"), "{head}"); + assert_eq!(http_body(&resp), b"", "no body after a 101"); +} + +#[test] +fn ws_handshake_wrong_version_426() { + let port = spawn_server(ws_pipeline()); + let resp = send_request( + port, + b"GET /ws HTTP/1.1\r\nHost: x\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\ + Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 8\r\n\r\n", + ); + assert_eq!(http_status(&resp), 426); + let head = String::from_utf8_lossy(&resp).to_lowercase(); + assert!(head.contains("sec-websocket-version: 13"), "{head}"); +} + +#[test] +fn ws_handshake_missing_key_400_keeps_http_alive() { + let port = spawn_server(ws_pipeline()); + // A rejected upgrade is a normal HTTP response: same connection must + // serve a second request afterwards. + let mut s = TcpStream::connect(("127.0.0.1", port)).unwrap(); + s.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); + s.write_all( + b"GET /ws HTTP/1.1\r\nHost: x\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\ + Sec-WebSocket-Version: 13\r\n\r\n", + ) + .unwrap(); + let mut buf = [0u8; 1024]; + let n = s.read(&mut buf).unwrap(); + let first = String::from_utf8_lossy(&buf[..n]); + assert!(first.starts_with("HTTP/1.1 400"), "{first}"); + + s.write_all( + b"GET /ws HTTP/1.1\r\nHost: x\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\ + Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\n\r\n", + ) + .unwrap(); + let n = s.read(&mut buf).unwrap(); + let second = String::from_utf8_lossy(&buf[..n]); + assert!(second.starts_with("HTTP/1.1 101"), "{second}"); +} + +#[test] +fn ws_accepted_upgrade_leaves_http_loop() { + // Chunk-1 semantics: after the 101 the actor exits and the fd drops — + // the client sees EOF after the 101 head. (Chunk 3 replaces the EOF + // with the ws duplex loop; this test will be rewritten then.) + let port = spawn_server(ws_pipeline()); + let mut s = TcpStream::connect(("127.0.0.1", port)).unwrap(); + s.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); + s.write_all( + b"GET /ws HTTP/1.1\r\nHost: x\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\ + Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\n\r\n", + ) + .unwrap(); + let mut buf = Vec::new(); + s.read_to_end(&mut buf).unwrap(); // EOF must arrive, not a hang + let head = String::from_utf8_lossy(&buf); + assert!(head.starts_with("HTTP/1.1 101"), "{head}"); +}