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:
2026-09-23 19:05:44 -07:00
co-authored by Claude Opus 5.5
parent f38dc8d474
commit 0339dc13b2
69 changed files with 7790 additions and 3 deletions
@@ -0,0 +1,101 @@
//! A fake `loopd` on a Unix socket: it records each turn and answers with the frames the test's
//! function gives for it. Do not edit.
#![allow(dead_code)] // each test file uses a different part of this module
use std::io::Write;
use std::os::unix::net::UnixListener;
use std::path::Path;
use std::sync::mpsc;
use proto::{
Envelope, ErrorCode, Message, PROTOCOL_VERSION, Turn, TurnDone, TurnEvent, Usage, WireError,
read_frame, write_frame,
};
const USAGE: Usage = Usage {
cache_n: 0,
prompt_n: 1,
predicted_n: 1,
reasoning_tokens: 0,
thinking_capped: false,
};
/// What the fake sends back for a turn.
pub enum Reply {
Frame(Envelope),
/// Raw bytes, for broken frames.
Bytes(Vec<u8>),
/// Stop answering this connection (the caller sees it close).
Close,
}
pub fn event(e: TurnEvent) -> Reply {
Reply::Frame(Envelope {
v: PROTOCOL_VERSION,
id: 1,
r#final: false,
msg: Message::TurnEvent(e),
})
}
pub fn done(content: &str) -> Reply {
let msg = Message::TurnDone(TurnDone {
content: content.to_string(),
usage: USAGE,
});
Reply::Frame(Envelope {
v: PROTOCOL_VERSION,
id: 1,
r#final: true,
msg,
})
}
pub fn error(code: ErrorCode, detail: &str) -> Reply {
let msg = Message::Error(WireError {
code,
detail: detail.to_string(),
});
Reply::Frame(Envelope {
v: PROTOCOL_VERSION,
id: 1,
r#final: true,
msg,
})
}
/// Listen on `socket`; `script(n, &turn)` gives the replies to the n-th turn (from 0).
pub fn serve_loop<F>(socket: &Path, script: F) -> mpsc::Receiver<Turn>
where
F: Fn(usize, &Turn) -> Vec<Reply> + Send + 'static,
{
let listener = UnixListener::bind(socket).unwrap();
let (tx, rx) = mpsc::channel();
std::thread::spawn(move || {
for (n, stream) in listener.incoming().enumerate() {
let Ok(mut stream) = stream else { continue };
let Ok(Envelope {
msg: Message::Turn(turn),
..
}) = read_frame(&mut stream)
else {
continue;
};
// Reported before the script runs: a script may wait for the test.
let _ = tx.send(turn.clone());
let replies = script(n, &turn);
for reply in replies {
let ok = match reply {
Reply::Frame(env) => write_frame(&mut stream, &env).is_ok(),
Reply::Bytes(b) => stream.write_all(&b).is_ok(),
Reply::Close => false,
};
if !ok {
break;
}
}
}
});
rx
}
@@ -0,0 +1,331 @@
//! A fake Mattermost on 127.0.0.1, plain TCP: the four REST calls `gatewayd` makes, and the
//! WebSocket, whose events the test sends and whose requests it reads. Do not edit.
#![allow(dead_code)] // each test file uses a different part of this module
use std::collections::HashMap;
use std::io::{Read, Write};
use std::net::{Shutdown, SocketAddr, TcpListener, TcpStream};
use std::sync::{Arc, Mutex, mpsc};
use std::time::{Duration, Instant};
use gatewayd::ws::handshake::accept_for;
use serde_json::{Value, json};
pub const BOT: &str = "b0000000000000000000000000";
pub const BOT_NAME: &str = "boxmaker-straylight";
pub const KYLE: &str = "k0000000000000000000000000";
pub const EVE: &str = "e0000000000000000000000000";
/// The direct channel between the bot and Kyle, and between the bot and anyone else.
pub const DM: &str = "d0000000000000000000000000";
pub const EVE_DM: &str = "f0000000000000000000000000";
#[derive(Default)]
struct Inner {
/// Status for `users/me`: 200 unless a test sets another.
me_status: u16,
/// The body for `channels/<id>/posts?since=`, by channel.
since: HashMap<String, Value>,
/// Every post made: channel, root, message.
posts: Vec<(String, String, String)>,
/// Every REST call: method and path.
calls: Vec<(String, String)>,
}
pub struct FakeMm {
pub addr: SocketAddr,
inner: Arc<Mutex<Inner>>,
sockets: Mutex<mpsc::Receiver<WsPeer>>,
}
/// One WebSocket connection from `gatewayd`.
pub struct WsPeer {
writer: TcpStream,
/// The text of every text frame `gatewayd` sends.
pub texts: mpsc::Receiver<String>,
}
/// A post as Mattermost sends it.
pub fn post(
id: &str,
user: &str,
channel: &str,
root: &str,
message: &str,
create_at: i64,
) -> Value {
json!({
"id": id, "create_at": create_at, "update_at": create_at, "delete_at": 0, "user_id": user,
"channel_id": channel, "root_id": root, "message": message, "type": "", "props": {}
})
}
pub fn id(prefix: char, n: u32) -> String {
format!("{prefix}{n:025}")
}
fn frame(opcode: u8, payload: &[u8]) -> Vec<u8> {
let mut out = vec![0x80 | opcode];
match payload.len() {
n if n < 126 => out.push(n as u8),
n => {
out.push(126);
out.extend_from_slice(&(n as u16).to_be_bytes());
}
}
out.extend_from_slice(payload);
out
}
impl WsPeer {
pub fn event(&mut self, value: &Value) {
let _ = self
.writer
.write_all(&frame(0x1, value.to_string().as_bytes()));
}
pub fn posted(&mut self, post: &Value, channel_type: &str) {
let data = json!({"post": post.to_string(), "channel_type": channel_type, "team_id": ""});
self.event(&json!({"event": "posted", "data": data, "broadcast": {}, "seq": 1}));
}
/// End the connection without a close frame.
pub fn drop_connection(self) {
let _ = self.writer.shutdown(Shutdown::Both);
}
/// The `user_typing` requests received within `wait`, as (channel, parent).
pub fn typing_within(&self, wait: Duration) -> Vec<(String, String)> {
let until = Instant::now() + wait;
let mut got = Vec::new();
while let Ok(text) = self
.texts
.recv_timeout(until.saturating_duration_since(Instant::now()))
{
let v: Value = serde_json::from_str(&text).unwrap();
if v["action"] == "user_typing" {
let data = &v["data"];
got.push((
data["channel_id"].as_str().unwrap().to_string(),
data["parent_id"].as_str().unwrap().to_string(),
));
}
}
got
}
}
fn read_head(stream: &mut TcpStream) -> Option<String> {
let mut head = Vec::new();
let mut byte = [0u8; 1];
while !head.ends_with(b"\r\n\r\n") {
if stream.read(&mut byte).ok()? == 0 {
return None;
}
head.push(byte[0]);
}
String::from_utf8(head).ok()
}
/// Unmask the client's frames and send each text on `tx`, until the connection ends.
fn read_frames(mut stream: TcpStream, tx: mpsc::Sender<String>) {
let mut exact = |n: usize| -> Option<Vec<u8>> {
let mut buf = vec![0u8; n];
stream.read_exact(&mut buf).ok().map(|()| buf)
};
loop {
let Some(head) = exact(2) else { return };
let len = match head[1] & 0x7F {
126 => u16::from_be_bytes(exact(2).unwrap().try_into().unwrap()) as usize,
127 => return,
n => n as usize,
};
let Some(mask) = exact(4) else { return };
let Some(raw) = exact(len) else { return };
let payload: Vec<u8> = raw
.iter()
.zip(mask.iter().cycle())
.map(|(b, m)| b ^ m)
.collect();
if head[0] & 0x0F == 0x1 {
let _ = tx.send(String::from_utf8(payload).unwrap());
}
}
}
impl FakeMm {
pub fn start() -> FakeMm {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let inner = Arc::new(Mutex::new(Inner {
me_status: 200,
..Inner::default()
}));
let (ws_tx, ws_rx) = mpsc::channel();
let shared = Arc::clone(&inner);
std::thread::spawn(move || {
for stream in listener.incoming() {
let Ok(stream) = stream else { continue };
let (inner, ws_tx) = (Arc::clone(&shared), ws_tx.clone());
std::thread::spawn(move || connection(stream, &inner, &ws_tx));
}
});
FakeMm {
addr,
inner,
sockets: Mutex::new(ws_rx),
}
}
pub fn url(&self) -> String {
format!("http://127.0.0.1:{}", self.addr.port())
}
pub fn refuse_token(&self) {
self.inner.lock().unwrap().me_status = 401;
}
pub fn set_since(&self, channel: &str, posts: &[Value]) {
let order: Vec<Value> = posts.iter().map(|p| p["id"].clone()).collect();
let map: serde_json::Map<String, Value> = posts
.iter()
.map(|p| (p["id"].as_str().unwrap().to_string(), p.clone()))
.collect();
self.inner
.lock()
.unwrap()
.since
.insert(channel.to_string(), json!({"order": order, "posts": map}));
}
/// The next WebSocket `gatewayd` opens, after its hello.
pub fn next_ws(&self, wait: Duration) -> WsPeer {
self.sockets
.lock()
.unwrap()
.recv_timeout(wait)
.expect("no WebSocket connection")
}
pub fn posts(&self) -> Vec<(String, String, String)> {
self.inner.lock().unwrap().posts.clone()
}
/// Wait until at least `n` posts were made, for at most `wait`.
pub fn wait_posts(&self, n: usize, wait: Duration) -> Vec<(String, String, String)> {
let until = Instant::now() + wait;
while self.posts().len() < n && Instant::now() < until {
std::thread::sleep(Duration::from_millis(10));
}
self.posts()
}
pub fn calls(&self) -> Vec<(String, String)> {
self.inner.lock().unwrap().calls.clone()
}
}
fn connection(mut stream: TcpStream, inner: &Mutex<Inner>, ws_tx: &mpsc::Sender<WsPeer>) {
let Some(head) = read_head(&mut stream) else {
return;
};
let mut words = head.split_whitespace();
let (method, path) = (
words.next().unwrap_or("").to_string(),
words.next().unwrap_or("").to_string(),
);
if path == "/api/v4/websocket" {
let key = head
.lines()
.find_map(|l| l.strip_prefix("Sec-WebSocket-Key: "))
.unwrap_or("")
.trim()
.to_string();
let reply = format!(
"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: {}\r\n\r\n",
accept_for(&key)
);
let _ = stream.write_all(reply.as_bytes());
let _ = stream.write_all(&frame(
0x1,
br#"{"event":"hello","data":{},"broadcast":{},"seq":0}"#,
));
let (tx, texts) = mpsc::channel();
let reader = stream.try_clone().unwrap();
std::thread::spawn(move || read_frames(reader, tx));
let _ = ws_tx.send(WsPeer {
writer: stream,
texts,
});
return;
}
let length = head
.lines()
.find_map(|l| {
l.to_ascii_lowercase()
.strip_prefix("content-length:")
.map(|v| v.trim().parse::<usize>().unwrap_or(0))
})
.unwrap_or(0);
let mut body = vec![0u8; length];
let _ = stream.read_exact(&mut body);
let (status, answer) = rest(inner, &method, &path, &body);
let text = answer.to_string();
let reply = format!(
"HTTP/1.1 {status} X\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{text}",
text.len()
);
let _ = stream.write_all(reply.as_bytes());
}
fn rest(inner: &Mutex<Inner>, method: &str, path: &str, body: &[u8]) -> (u16, Value) {
let mut inner = inner.lock().unwrap();
inner.calls.push((method.to_string(), path.to_string()));
match (method, path) {
("GET", "/api/v4/users/me") if inner.me_status == 200 => {
(200, json!({"id": BOT, "username": BOT_NAME}))
}
("GET", "/api/v4/users/me") => (
inner.me_status,
json!({"id": "api.context.session_expired.app_error"}),
),
("POST", "/api/v4/channels/direct") => {
let users: Vec<String> = serde_json::from_slice(body).unwrap();
let channel = if users.iter().any(|u| u == KYLE) {
DM
} else {
EVE_DM
};
(201, json!({"id": channel, "type": "D"}))
}
("POST", "/api/v4/posts") => {
let p: Value = serde_json::from_slice(body).unwrap();
let n = u32::try_from(inner.posts.len()).unwrap();
let (channel, root, message) = (
p["channel_id"].as_str().unwrap(),
p["root_id"].as_str().unwrap(),
p["message"].as_str().unwrap(),
);
inner
.posts
.push((channel.to_string(), root.to_string(), message.to_string()));
(201, post(&id('x', n), BOT, channel, root, message, 1))
}
("GET", p) if p.contains("/posts?since=") => {
let channel = p
.trim_start_matches("/api/v4/channels/")
.split('/')
.next()
.unwrap_or("");
(
200,
inner
.since
.get(channel)
.cloned()
.unwrap_or(json!({"order": [], "posts": {}})),
)
}
_ => (404, json!({"message": "not found"})),
}
}
@@ -0,0 +1,141 @@
//! Running `gatewayd`'s serve loop in a test, against the fake Mattermost and the fake `loopd`.
//! Do not edit.
#![allow(dead_code)] // each test file uses a different part of this module
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, mpsc};
use std::thread::JoinHandle;
use std::time::Duration;
use gatewayd::config::Config;
use gatewayd::secrets::Secret;
use gatewayd::serve::{Stop, Tuning, run};
use proto::Turn;
use crate::fake_loop::{done, serve_loop};
use crate::fake_mm::{FakeMm, KYLE, WsPeer};
use crate::tmp::TempDir;
pub const SHARED: &str = "c0000000000000000000000000";
pub const OTHER: &str = "o0000000000000000000000000";
pub const WAIT: Duration = Duration::from_secs(5);
pub struct Running {
pub stop: Arc<AtomicBool>,
pub log: Arc<Mutex<Vec<String>>>,
pub handle: Option<JoinHandle<Stop>>,
}
impl Running {
pub fn log(&self) -> Vec<String> {
self.log.lock().unwrap().clone()
}
pub fn wait_log(&self, part: &str) -> Vec<String> {
let until = std::time::Instant::now() + WAIT;
while !self.log().iter().any(|l| l.contains(part)) {
assert!(
std::time::Instant::now() < until,
"no log line with {part:?}: {:?}",
self.log()
);
std::thread::sleep(Duration::from_millis(10));
}
self.log()
}
/// The `Stop` `run` returns by itself within 5 s; after that it is stopped, and the test fails.
pub fn join_within(mut self) -> Stop {
let handle = self.handle.take().unwrap();
let until = std::time::Instant::now() + WAIT;
while !handle.is_finished() && std::time::Instant::now() < until {
std::thread::sleep(Duration::from_millis(10));
}
self.stop.store(true, Ordering::SeqCst);
let stop = handle.join().unwrap();
assert!(!matches!(stop, Stop::Asked), "run did not stop by itself");
stop
}
pub fn finish(mut self) -> Stop {
self.stop.store(true, Ordering::SeqCst);
self.handle.take().unwrap().join().unwrap()
}
}
impl Drop for Running {
fn drop(&mut self) {
self.stop.store(true, Ordering::SeqCst);
}
}
pub fn config(home: &TempDir, url: &str, extra: &str) -> Config {
let text = format!(
r#"
[mattermost]
url = "{url}"
[secrets.mattermost_token]
env = "NOT_READ_BY_RUN"
[allow]
users = ["{KYLE}"]
channels = ["{SHARED}"]
[paths]
home = "{}"
[limits]
typing_every_ms = 100
{extra}
"#,
home.path().display()
);
Config::parse(&text).unwrap()
}
pub fn start(config: Config) -> Running {
let stop = Arc::new(AtomicBool::new(false));
let log = Arc::new(Mutex::new(Vec::new()));
let tuning = Tuning {
backoff: vec![Duration::from_millis(50)],
poll: Duration::from_millis(20),
rest_timeout: WAIT,
};
let (s, l) = (Arc::clone(&stop), Arc::clone(&log));
let handle = std::thread::spawn(move || {
let sink: gatewayd::serve::Log =
Arc::new(move |line: &str| l.lock().unwrap().push(line.to_string()));
run(config, Secret::new("TOKEN".to_string()), tuning, sink, &s)
});
Running {
stop,
log,
handle: Some(handle),
}
}
/// A fake loop that answers every turn with "answer to <content>", after `delay`.
pub fn answering(home: &TempDir, delay: Duration) -> mpsc::Receiver<Turn> {
serve_loop(&home.path().join("run/loop/loop.sock"), move |_, turn| {
std::thread::sleep(delay);
vec![done(&format!("answer to {}", turn.content))]
})
}
pub fn read_state(home: &TempDir) -> serde_json::Value {
let text = std::fs::read_to_string(home.path().join("gateway/state.json")).unwrap();
serde_json::from_str(&text).unwrap()
}
pub fn loop_dir(home: &TempDir) {
std::fs::create_dir_all(home.path().join("run/loop")).unwrap();
}
/// Start with a fake Mattermost and a fake loop; the first WebSocket is returned.
pub fn up(home: &TempDir, delay: Duration) -> (FakeMm, Running, mpsc::Receiver<Turn>, WsPeer) {
loop_dir(home);
let turns = answering(home, delay);
let mm = FakeMm::start();
let running = start(config(home, &mm.url(), ""));
let ws = mm.next_ws(WAIT);
running.wait_log("gatewayd: connected to ");
(mm, running, turns, ws)
}
@@ -0,0 +1,93 @@
//! A scripted HTTP server for tests: it records each request and answers with what the test's
//! function returns for it. Built on `tls_server`. Do not edit.
#![allow(dead_code)] // each test file uses a different part of this module
use std::io::{Read, Write};
use std::net::SocketAddr;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex, mpsc};
use rustls::ServerConfig;
use crate::tls_server::serve;
#[derive(Debug, Clone)]
pub struct Request {
pub method: String,
pub path: String,
/// The whole head, for tests that look for a header.
pub head: String,
pub body: Vec<u8>,
}
impl Request {
pub fn json(&self) -> serde_json::Value {
serde_json::from_slice(&self.body).unwrap()
}
}
/// A response with a JSON body.
pub fn reply(status: u16, extra_headers: &str, body: &str) -> String {
format!(
"HTTP/1.1 {status} X\r\nContent-Type: application/json\r\n{extra_headers}Content-Length: {}\r\n\r\n{body}",
body.len()
)
}
fn read_request(conn: &mut dyn Read) -> Option<Request> {
let mut head = Vec::new();
let mut byte = [0u8; 1];
while !head.ends_with(b"\r\n\r\n") {
if conn.read(&mut byte).ok()? == 0 {
return None;
}
head.push(byte[0]);
}
let head = String::from_utf8(head).ok()?;
let mut first = head.split_whitespace();
let method = first.next()?.to_string();
let path = first.next()?.to_string();
let length = head
.lines()
.find_map(|l| {
l.to_ascii_lowercase()
.strip_prefix("content-length:")
.map(|v| v.trim().parse().ok())
})
.flatten()
.unwrap_or(0);
let mut body = vec![0u8; length];
conn.read_exact(&mut body).ok()?;
Some(Request {
method,
path,
head,
body,
})
}
/// Serve requests: `answer(n, &request)` gives the response to the n-th request (from 0). Every
/// request is sent on the returned channel.
pub fn serve_http<F>(
tls: Option<Arc<ServerConfig>>,
answer: F,
) -> (SocketAddr, mpsc::Receiver<Request>)
where
F: Fn(usize, &Request) -> String + Send + Sync + 'static,
{
let (tx, rx) = mpsc::channel();
let tx = Mutex::new(tx);
let count = AtomicUsize::new(0);
let addr = serve(tls, move |mut conn| {
let Some(request) = read_request(&mut conn) else {
return;
};
let n = count.fetch_add(1, Ordering::SeqCst);
let response = answer(n, &request);
let _ = tx.lock().unwrap().send(request);
let _ = conn.write_all(response.as_bytes());
let _ = conn.flush();
});
(addr, rx)
}
@@ -0,0 +1,82 @@
//! Small TCP and TLS servers for tests, using the TEST-ONLY certificates in `fixtures/tls/`.
//! Each serves connections on its own thread with a function of the connection. Do not edit.
#![allow(dead_code)] // each test file uses a different part of this module
use std::io::{Read, Write};
use std::net::{SocketAddr, TcpListener, TcpStream};
use std::path::PathBuf;
use std::sync::Arc;
use rustls::pki_types::pem::PemObject;
use rustls::pki_types::{CertificateDer, PrivateKeyDer};
use rustls::{ServerConfig, ServerConnection, StreamOwned};
pub fn fixture(name: &str) -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/tls")
.join(name)
}
/// A server certificate (`server`, `wrong-name`, `other-server`) and its key.
pub fn server_config(which: &str) -> Arc<ServerConfig> {
let certs: Vec<CertificateDer<'static>> =
CertificateDer::pem_file_iter(fixture(&format!("{which}.pem")))
.unwrap()
.collect::<Result<_, _>>()
.unwrap();
let key = PrivateKeyDer::from_pem_file(fixture(&format!("{which}.key"))).unwrap();
let provider = Arc::new(rustls::crypto::ring::default_provider());
let config = ServerConfig::builder_with_provider(provider)
.with_safe_default_protocol_versions()
.unwrap()
.with_no_client_auth()
.with_single_cert(certs, key)
.unwrap();
Arc::new(config)
}
/// Anything a test server can serve: plain TCP, or TLS over it.
pub trait Conn: Read + Write + Send {}
impl<T: Read + Write + Send> Conn for T {}
/// Serve every connection on 127.0.0.1 with `handle`, in plain TCP (`tls` None) or TLS.
pub fn serve<F>(tls: Option<Arc<ServerConfig>>, handle: F) -> SocketAddr
where
F: Fn(Box<dyn Conn>) + Send + Sync + 'static,
{
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let handle = Arc::new(handle);
std::thread::spawn(move || {
for stream in listener.incoming() {
let Ok(stream) = stream else { continue };
let handle = Arc::clone(&handle);
let tls = tls.clone();
std::thread::spawn(move || match tls {
None => handle(Box::new(stream)),
Some(config) => {
let conn = ServerConnection::new(config).unwrap();
let tls_stream: StreamOwned<ServerConnection, TcpStream> =
StreamOwned::new(conn, stream);
handle(Box::new(tls_stream));
}
});
}
});
addr
}
/// A handler that reads one line and writes it back.
pub fn echo_line(mut conn: Box<dyn Conn>) {
let mut line = Vec::new();
let mut byte = [0u8; 1];
while conn.read(&mut byte).map(|n| n == 1).unwrap_or(false) {
line.push(byte[0]);
if byte[0] == b'\n' {
break;
}
}
let _ = conn.write_all(&line);
let _ = conn.flush();
}
@@ -0,0 +1,40 @@
//! Temporary directories for tests. Do not edit.
#![allow(dead_code)] // each test file uses a different part of this module
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU32, Ordering};
static NEXT: AtomicU32 = AtomicU32::new(0);
pub struct TempDir(PathBuf);
impl TempDir {
pub fn new(tag: &str) -> TempDir {
let n = NEXT.fetch_add(1, Ordering::SeqCst);
let path = std::env::temp_dir().join(format!("gw-{tag}-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&path);
std::fs::create_dir_all(&path).unwrap();
TempDir(path)
}
pub fn path(&self) -> &Path {
&self.0
}
/// Writes `text` to `name` inside the directory and returns the full path.
pub fn write(&self, name: &str, text: &str) -> PathBuf {
let path = self.0.join(name);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).unwrap();
}
std::fs::write(&path, text).unwrap();
path
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
@@ -0,0 +1,132 @@
//! A scripted WebSocket server for tests: it accepts one handshake per connection and then lets
//! the test send raw frames and read the client's. Built on `tls_server`. Do not edit.
#![allow(dead_code)] // each test file uses a different part of this module
use std::io::{Read, Write};
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;
use gatewayd::ws::handshake::accept_for;
use rustls::ServerConfig;
use crate::tls_server::{Conn, serve};
pub struct Peer {
pub conn: Box<dyn Conn>,
/// The request head the client sent, for tests that check it.
pub request: String,
}
/// A frame from the client: opcode, whether it was masked, and the unmasked payload.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClientFrame {
pub opcode: u8,
pub masked: bool,
pub mask: [u8; 4],
pub payload: Vec<u8>,
}
impl Peer {
pub fn send(&mut self, bytes: &[u8]) {
let _ = self.conn.write_all(bytes);
let _ = self.conn.flush();
}
/// An unmasked server frame, FIN set.
pub fn frame(&mut self, opcode: u8, payload: &[u8]) {
let mut out = vec![0x80 | opcode];
if payload.len() < 126 {
out.push(payload.len() as u8);
} else {
out.push(126);
out.extend_from_slice(&(payload.len() as u16).to_be_bytes());
}
out.extend_from_slice(payload);
self.send(&out);
}
pub fn text(&mut self, text: &str) {
self.frame(0x1, text.as_bytes());
}
fn read_exact(&mut self, n: usize) -> Option<Vec<u8>> {
let mut buf = vec![0u8; n];
self.conn.read_exact(&mut buf).ok()?;
Some(buf)
}
/// The next frame from the client, or `None` when it has gone.
pub fn read_frame(&mut self) -> Option<ClientFrame> {
let head = self.read_exact(2)?;
let opcode = head[0] & 0x0F;
let masked = head[1] & 0x80 != 0;
let len = match head[1] & 0x7F {
126 => u16::from_be_bytes(self.read_exact(2)?.try_into().ok()?) as usize,
127 => u64::from_be_bytes(self.read_exact(8)?.try_into().ok()?) as usize,
n => n as usize,
};
let mask: [u8; 4] = if masked {
self.read_exact(4)?.try_into().ok()?
} else {
[0; 4]
};
let raw = self.read_exact(len)?;
let payload = raw
.iter()
.zip(mask.iter().cycle())
.map(|(b, m)| b ^ m)
.collect();
Some(ClientFrame {
opcode,
masked,
mask,
payload,
})
}
pub fn pause(&self, d: Duration) {
std::thread::sleep(d);
}
}
/// Serve WebSocket connections: complete the handshake (or answer `refuse_with` instead), then run
/// `script` on the connection.
pub fn serve_ws<F>(
tls: Option<Arc<ServerConfig>>,
refuse_with: Option<&'static str>,
script: F,
) -> SocketAddr
where
F: Fn(Peer) + Send + Sync + 'static,
{
serve(tls, move |mut conn| {
let mut head = Vec::new();
let mut byte = [0u8; 1];
while !head.ends_with(b"\r\n\r\n") {
if conn.read(&mut byte).map(|n| n == 0).unwrap_or(true) {
return;
}
head.push(byte[0]);
}
let request = String::from_utf8_lossy(&head).into_owned();
if let Some(reply) = refuse_with {
let _ = conn.write_all(reply.as_bytes());
return;
}
let key = request
.lines()
.find_map(|l| l.strip_prefix("Sec-WebSocket-Key: "))
.unwrap_or_default()
.trim()
.to_string();
let reply = format!(
"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: {}\r\n\r\n",
accept_for(&key)
);
let _ = conn.write_all(reply.as_bytes());
let _ = conn.flush();
script(Peer { conn, request });
})
}