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