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:
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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");
|
||||||
|
|||||||
@@ -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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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: (),
|
||||||
|
}
|
||||||
@@ -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}");
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user