Plan M4a: gatewayd in 15 tasks, with skeletons and given tests
Each task's tests were run against a reference at its end state; the end states were replayed from master in order with the gate at each step (650 to 762 tests); each skeleton compiles against its tests and fails them. The reference is kept off this machine. Lessons T27 (every wait in a test has a limit) and T28 (mutate the reference before hand-over) come from this work. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
//! One WebSocket connection to Mattermost (M4a spec, section 6): open it, send text, and poll for
|
||||
//! the next text message while answering pings, sending our own, and noticing a dead peer.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::config::ServerUrl;
|
||||
use crate::net::{Connector, Stream};
|
||||
use crate::ws::WsError;
|
||||
use crate::ws::frame::{CLOSE, Decoder, Incoming, PING, PONG, TEXT, encode};
|
||||
use crate::ws::handshake::handshake;
|
||||
|
||||
/// Mattermost's WebSocket path.
|
||||
pub const PATH: &str = "/api/v4/websocket";
|
||||
|
||||
/// How often we ping, and how long silence may last.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Timing {
|
||||
pub ping_every: Duration,
|
||||
pub dead_after: Duration,
|
||||
}
|
||||
|
||||
pub struct Ws {
|
||||
stream: Stream,
|
||||
decoder: Decoder,
|
||||
random: Box<dyn Read + Send>,
|
||||
timing: Timing,
|
||||
last_heard: Instant,
|
||||
last_ping: Instant,
|
||||
}
|
||||
|
||||
impl Ws {
|
||||
/// Connect, and complete the handshake with `token`. `random` supplies the key and every mask
|
||||
/// (in `gatewayd`, `/dev/urandom`).
|
||||
pub fn open(
|
||||
connector: &Connector,
|
||||
token: &str,
|
||||
timing: Timing,
|
||||
mut random: Box<dyn Read + Send>,
|
||||
) -> Result<Ws, WsError> {
|
||||
// 1. `connector.connect(timing.dead_after)`; its error becomes
|
||||
// WsError::Handshake(e.to_string()).
|
||||
// 2. `handshake(&mut stream, &host_header(connector.server()), PATH, token, &mut random)?`.
|
||||
// 3. A Ws with a new Decoder, and last_heard and last_ping both now.
|
||||
todo!()
|
||||
}
|
||||
|
||||
fn send(&mut self, opcode: u8, payload: &[u8]) -> Result<(), WsError> {
|
||||
// 4 mask bytes from `random` (read_exact), then write `encode(opcode, payload, mask)` and
|
||||
// flush.
|
||||
todo!()
|
||||
}
|
||||
|
||||
pub fn send_text(&mut self, text: &str) -> Result<(), WsError> {
|
||||
// `send` with TEXT.
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// The next text message, or `None` after about `wait` with none. Pings are answered and sent
|
||||
/// here; a close frame is answered and ends the connection (`Closed`); silence past the
|
||||
/// dead-after limit is `Dead`.
|
||||
pub fn poll(&mut self, wait: Duration) -> Result<Option<String>, WsError> {
|
||||
// Loop, with `until = now + wait`:
|
||||
// 1. Every whole message the decoder has: Text -> return it; Ping(p) -> send PONG with p;
|
||||
// Pong -> nothing; Close(code, _) -> send CLOSE with the code as 2 bytes (or empty),
|
||||
// ignore that error, return Closed.
|
||||
// 2. Silence since last_heard >= dead_after -> Dead.
|
||||
// 3. Since last_ping >= ping_every -> send an empty PING, last_ping = now.
|
||||
// 4. now >= until -> Ok(None).
|
||||
// 5. Read timeout: the least of (until - now), (last_ping + ping_every - now) and
|
||||
// (last_heard + dead_after - now), at least 1 ms. Read into a 16 KiB buffer: 0 bytes ->
|
||||
// Closed; n bytes -> feed them, last_heard = now; WouldBlock, TimedOut or Interrupted ->
|
||||
// go round; any other error -> Io.
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// Send a close frame, best effort, and drop the connection.
|
||||
pub fn close(mut self) {
|
||||
// Send CLOSE with 1000 as 2 big-endian bytes; ignore the error.
|
||||
todo!()
|
||||
}
|
||||
}
|
||||
|
||||
/// The `Host` header for a server: the port is written only when it is not the scheme's default.
|
||||
pub fn host_header(server: &ServerUrl) -> String {
|
||||
// The host alone when the port is the default for the scheme (443 for tls, 80 otherwise), else
|
||||
// "host:port".
|
||||
todo!()
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
//! WebSocket frames (RFC 6455, section 5), without I/O. `Decoder` is fed the bytes as they arrive,
|
||||
//! in any pieces, and yields whole messages; `encode` builds our masked frames. Everything the
|
||||
//! server sends is untrusted: every length is checked before anything is allocated.
|
||||
|
||||
use crate::ws::WsError;
|
||||
|
||||
/// The largest message we accept, counted from the length fields.
|
||||
pub const MAX_MESSAGE: usize = 1 << 20;
|
||||
|
||||
pub const CONTINUATION: u8 = 0x0;
|
||||
pub const TEXT: u8 = 0x1;
|
||||
pub const CLOSE: u8 = 0x8;
|
||||
pub const PING: u8 = 0x9;
|
||||
pub const PONG: u8 = 0xA;
|
||||
|
||||
/// A whole message from the server.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Incoming {
|
||||
Text(String),
|
||||
Ping(Vec<u8>),
|
||||
Pong(Vec<u8>),
|
||||
/// A close frame: the status code if there is one, and the reason.
|
||||
Close(Option<u16>, String),
|
||||
}
|
||||
|
||||
/// Reassembles frames into messages.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Decoder {
|
||||
buf: Vec<u8>,
|
||||
/// A text message whose first frame has come and whose last has not.
|
||||
partial: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
/// A parsed header: what it says and how long it is.
|
||||
struct Header {
|
||||
fin: bool,
|
||||
opcode: u8,
|
||||
header_len: usize,
|
||||
payload_len: usize,
|
||||
}
|
||||
|
||||
impl Decoder {
|
||||
pub fn new() -> Decoder {
|
||||
Decoder::default()
|
||||
}
|
||||
|
||||
/// Append bytes as they arrived.
|
||||
pub fn feed(&mut self, bytes: &[u8]) {
|
||||
self.buf.extend_from_slice(bytes);
|
||||
}
|
||||
|
||||
/// The next whole message, `None` if more bytes are needed, or the error that ends the
|
||||
/// connection. After an error, do not call again.
|
||||
pub fn next_message(&mut self) -> Result<Option<Incoming>, WsError> {
|
||||
// Loop: `self.header()?`, None -> Ok(None). If the buffer holds less than header_len +
|
||||
// payload_len, Ok(None). Otherwise take the payload out and drain the frame from the
|
||||
// buffer, then by opcode: PING -> Ping, PONG -> Pong, CLOSE -> `close(&payload)`. TEXT
|
||||
// starts a new message, CONTINUATION extends `partial`; with fin the whole message must be
|
||||
// UTF-8 (else Protocol) and is returned as Text; without fin it is kept in `partial` and
|
||||
// the loop goes on. Any other opcode is Protocol.
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// The next frame's header, once it is all here, checked against every rule that does not need
|
||||
/// the payload.
|
||||
fn header(&self) -> Result<Option<Header>, WsError> {
|
||||
// Return Ok(None) while the bytes the header needs have not all arrived. The rules, each an
|
||||
// error: a reserved bit (0x70 of byte 0) is Protocol; the mask bit (0x80 of byte 1) is
|
||||
// Protocol; an opcode that is not CONTINUATION, TEXT, CLOSE, PING or PONG is Protocol.
|
||||
// Length 126: a u16 in the next 2 bytes, below 126 is Protocol (not the shortest form).
|
||||
// 127: a u64 in the next 8 bytes; top bit set is Protocol; <= 0xFFFF is Protocol. Control
|
||||
// frames (opcode & 0x8): not fin, or over 125 bytes, is Protocol. Data frames: TEXT while
|
||||
// `partial` is Some, or CONTINUATION while it is None, is Protocol; a payload over
|
||||
// MAX_MESSAGE minus what `partial` holds is TooLarge. All of it before any allocation.
|
||||
todo!()
|
||||
}
|
||||
}
|
||||
|
||||
fn close(payload: &[u8]) -> Result<Incoming, WsError> {
|
||||
// Empty: Close(None, ""). One byte: Protocol. Otherwise a big-endian u16 code and a UTF-8
|
||||
// reason (else Protocol).
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// One whole frame from us: FIN set, masked with `mask`.
|
||||
pub fn encode(opcode: u8, payload: &[u8], mask: [u8; 4]) -> Vec<u8> {
|
||||
// Byte 0: 0x80 | opcode. Byte 1: 0x80 | length, where the length is the 7-bit form below 126,
|
||||
// 126 then a u16 up to 0xFFFF, else 127 then a u64. Then the 4 mask bytes, then each payload
|
||||
// byte XOR mask[i % 4]. No `as` casts: use try_from.
|
||||
todo!()
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
//! The opening handshake (RFC 6455, section 4.1) and the base64 it needs.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
|
||||
use crate::http::{Head, read_head};
|
||||
use crate::ws::WsError;
|
||||
|
||||
/// RFC 6455's magic string, appended to the key before hashing.
|
||||
pub const GUID: &str = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
|
||||
|
||||
const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
|
||||
/// Standard base64 with padding (RFC 4648, section 4).
|
||||
pub fn base64(bytes: &[u8]) -> String {
|
||||
// Standard alphabet (ALPHABET), "=" padding: each 3 bytes become 4 characters; a last group of
|
||||
// 1 or 2 bytes becomes 2 or 3 characters and 2 or 1 "=". No indexing that can go out of bounds.
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// The `Sec-WebSocket-Accept` a server must send for `key`.
|
||||
pub fn accept_for(key: &str) -> String {
|
||||
// base64(sha1(key + GUID)), with `proto::sha1::sha1`.
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// A fresh key: 16 bytes from `random` (in `gatewayd`, `/dev/urandom`), in base64.
|
||||
pub fn new_key(random: &mut dyn Read) -> std::io::Result<String> {
|
||||
// 16 bytes read from `random` with read_exact, then base64.
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// The request, exactly.
|
||||
pub fn request_text(host: &str, path: &str, key: &str, token: &str) -> String {
|
||||
format!(
|
||||
"GET {path} HTTP/1.1\r\nHost: {host}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\
|
||||
Sec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\nAuthorization: Bearer {token}\r\n\r\n"
|
||||
)
|
||||
}
|
||||
|
||||
/// Is `head` a server's acceptance of `key`? Status 101, `Upgrade: websocket`, a `Connection`
|
||||
/// holding the token `upgrade`, and the right `Sec-WebSocket-Accept` (case matters there).
|
||||
pub fn check_response(head: &Head, key: &str) -> Result<(), WsError> {
|
||||
// In this order, each a Handshake error: status is not 101 ("status <n>"); no Upgrade header
|
||||
// equal to "websocket" ignoring case; no Connection header with a comma-separated token equal
|
||||
// to "upgrade" ignoring case; Sec-WebSocket-Accept missing, or not exactly `accept_for(key)`.
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// The whole handshake on `stream`. Nothing after the server's head is read.
|
||||
pub fn handshake(
|
||||
stream: &mut (impl Read + Write),
|
||||
host: &str,
|
||||
path: &str,
|
||||
token: &str,
|
||||
random: &mut dyn Read,
|
||||
) -> Result<(), WsError> {
|
||||
// A new key; write `request_text` and flush; `read_head` (its error is a Handshake error); then
|
||||
// `check_response`. Read nothing after the head.
|
||||
todo!()
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
//! The WebSocket client (RFC 6455; M4a spec, section 6): the handshake, the frame codec, and the
|
||||
//! connection that uses them.
|
||||
|
||||
pub mod handshake;
|
||||
|
||||
/// Why a WebSocket ended or could not start. Every one of these ends the connection; `gatewayd`
|
||||
/// then reconnects.
|
||||
#[derive(Debug)]
|
||||
pub enum WsError {
|
||||
/// The server's answer to the handshake was not an upgrade to a WebSocket.
|
||||
Handshake(String),
|
||||
/// A frame broke the protocol.
|
||||
Protocol(String),
|
||||
/// A message over `MAX_MESSAGE`, refused from its length fields.
|
||||
TooLarge,
|
||||
/// The server closed the connection (a close frame, or the end of the stream).
|
||||
Closed,
|
||||
/// Nothing was heard for the dead-after limit.
|
||||
Dead,
|
||||
Io(std::io::Error),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for WsError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
WsError::Handshake(why) => write!(f, "WebSocket handshake failed: {why}"),
|
||||
WsError::Protocol(why) => write!(f, "WebSocket protocol error: {why}"),
|
||||
WsError::TooLarge => write!(f, "WebSocket message too large"),
|
||||
WsError::Closed => write!(f, "WebSocket closed"),
|
||||
WsError::Dead => write!(f, "WebSocket silent for too long"),
|
||||
WsError::Io(e) => write!(f, "WebSocket I/O: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for WsError {}
|
||||
|
||||
impl From<std::io::Error> for WsError {
|
||||
fn from(e: std::io::Error) -> Self {
|
||||
WsError::Io(e)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user