feat(ws): RFC 6455 opening handshake — Conn::upgrade, 101 short-circuit (v0.4 chunk 1)

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<WsUpgrade> (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.
This commit is contained in:
Claude
2026-06-12 05:38:14 +00:00
parent 43bd5a91a4
commit 49538ccc83
8 changed files with 435 additions and 1 deletions
+1
View File
@@ -9,6 +9,7 @@ description = "Cowboy/bandit-style HTTP library for the smarm actor runtime"
smarm = { path = "../smarm" } smarm = { path = "../smarm" }
httparse = "1.9" httparse = "1.9"
libc = "0.2" libc = "0.2"
sha1_smol = "1"
[features] [features]
smarm-trace = ["smarm/smarm-trace"] smarm-trace = ["smarm/smarm-trace"]
+47
View File
@@ -308,6 +308,11 @@ pub struct Conn {
pub params: Params, pub params: Params,
pub assigns: Assigns, pub assigns: Assigns,
pub halted: bool, 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<crate::ws::WsUpgrade>,
} }
impl Conn { impl Conn {
@@ -329,6 +334,8 @@ impl Conn {
params: Params::new(), params: Params::new(),
assigns: Assigns::new(), assigns: Assigns::new(),
halted: false, halted: false,
upgrade: None,
} }
} }
@@ -358,6 +365,46 @@ impl Conn {
self.halted = true; self.halted = true;
self 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 { impl Default for Conn {
+14
View File
@@ -231,6 +231,20 @@ pub fn run_connection(
.put_body(RespBody::Empty); .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. ----- // ----- 4. Write the response. -----
// A Stream body on HTTP/1.0 has no chunked framing: the body is // 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 // delimited by EOF, so keep-alive is forced off for this response
+1
View File
@@ -27,6 +27,7 @@ pub mod conn_actor;
pub mod conn_registry; pub mod conn_registry;
pub mod serve; pub mod serve;
pub mod sse; pub mod sse;
pub mod ws;
// Re-exports — what most users want at the crate root. // Re-exports — what most users want at the crate root.
pub use conn::{Assigns, Body, Conn, HeaderMap, HttpVersion, Method, Params, RespBody, StreamBody}; pub use conn::{Assigns, Body, Conn, HeaderMap, HttpVersion, Method, Params, RespBody, StreamBody};
+4 -1
View File
@@ -243,7 +243,10 @@ pub fn serialise_response(conn: &Conn, keep_alive: bool) -> Vec<u8> {
if conn.version == HttpVersion::Http11 { if conn.version == HttpVersion::Http11 {
out.extend_from_slice(b"transfer-encoding: chunked\r\n"); 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(b"content-length: ");
out.extend_from_slice(body_len.to_string().as_bytes()); out.extend_from_slice(body_len.to_string().as_bytes());
out.extend_from_slice(b"\r\n"); out.extend_from_slice(b"\r\n");
+250
View File
@@ -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<String, Rejection> {
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()));
}
}
+26
View File
@@ -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: (),
}
+92
View File
@@ -967,3 +967,95 @@ fn sse_heartbeats_on_silence() {
let beats = body.matches(": keep-alive").count(); let beats = body.matches(": keep-alive").count();
assert!(beats >= 2, "expected >=2 heartbeats in 450ms at 100ms interval, got {beats}: {body:?}"); 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}");
}