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,194 @@
//! `gatewayd.toml` into a typed `Config`. Our own format: unknown keys are errors in every table.
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use serde::Deserialize;
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Config {
pub mattermost: MattermostConfig,
pub secrets: BTreeMap<String, SecretSpec>,
pub allow: AllowConfig,
#[serde(default, rename = "loop")]
pub loop_: LoopConfig,
#[serde(default)]
pub paths: Paths,
#[serde(default)]
pub limits: Limits,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MattermostConfig {
pub url: String,
#[serde(default)]
pub ca_file: Option<PathBuf>,
}
/// Where one secret comes from: exactly one of the three is set (checked by `Config::load`).
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct SecretSpec {
#[serde(default)]
pub credential: Option<String>,
#[serde(default)]
pub env: Option<String>,
#[serde(default)]
pub file: Option<PathBuf>,
}
/// A checked `SecretSpec`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SecretSource {
Credential(String),
Env(String),
File(PathBuf),
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AllowConfig {
pub users: Vec<String>,
#[serde(default)]
pub channels: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Default)]
#[serde(deny_unknown_fields, default)]
pub struct LoopConfig {
pub socket: PathBuf,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields, default)]
pub struct Paths {
pub home: PathBuf,
}
impl Default for Paths {
fn default() -> Self {
Paths {
home: std::env::var_os("BOXMAKER_HOME")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("/var/lib/boxmaker")),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields, default)]
pub struct Limits {
pub queue: u32,
pub typing_every_ms: u64,
pub ping_every_ms: u64,
pub dead_after_ms: u64,
}
impl Default for Limits {
fn default() -> Self {
Limits {
queue: 20,
typing_every_ms: 3_000,
ping_every_ms: 30_000,
dead_after_ms: 60_000,
}
}
}
/// `url` taken apart.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ServerUrl {
pub tls: bool,
pub host: String,
pub port: u16,
}
#[derive(Debug)]
pub enum ConfigError {
Read(PathBuf, std::io::Error),
Parse(PathBuf, toml::de::Error),
Invalid(PathBuf, String),
}
impl std::fmt::Display for ConfigError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
// Each variant: "<path>: <error or why>", with `path.display()`.
todo!()
}
}
impl std::error::Error for ConfigError {}
/// The one secret M4a needs.
pub const MATTERMOST_TOKEN: &str = "mattermost_token";
impl Config {
/// Parse without the checks `load` makes.
pub fn parse(text: &str) -> Result<Config, toml::de::Error> {
// `toml::from_str`.
todo!()
}
pub fn load(path: &Path) -> Result<Config, ConfigError> {
// Read the file (else Read), parse (else Parse), then `problem()` (Some(why) is Invalid).
todo!()
}
/// The first thing wrong with the values, or `None`.
pub fn problem(&self) -> Option<String> {
// The first of these, in this order, with the exact messages in the task: the url
// (`parse_url`); ca_file not absolute; [secrets.mattermost_token] missing; each secret
// whose `source()` fails; allow.users empty; any id in allow.users or allow.channels not
// `valid_id`; any limit that is 0.
todo!()
}
/// The server's address, from `url`. Call only on a checked `Config`.
pub fn server(&self) -> Result<ServerUrl, String> {
// `parse_url` of the url.
todo!()
}
pub fn token_source(&self) -> Result<SecretSource, String> {
// The `source()` of [secrets.mattermost_token], or an error if it is missing.
todo!()
}
pub fn loop_socket(&self) -> PathBuf {
// [loop] socket, or <home>/run/loop/loop.sock when it is empty.
todo!()
}
pub fn state_path(&self) -> PathBuf {
// <home>/gateway/state.json.
todo!()
}
}
impl SecretSpec {
/// Exactly one source, well formed.
pub fn source(&self) -> Result<SecretSource, String> {
// Exactly one of the three set, else "needs exactly one of credential, env and file".
// credential: not empty, only ASCII letters, digits, _ . -. env: not empty, only A-Z, 0-9,
// _. file: an absolute path. The messages are in the task.
todo!()
}
}
/// A Mattermost id: 26 characters of `a-z0-9`.
pub fn valid_id(id: &str) -> bool {
// 26 bytes, each a-z or 0-9.
todo!()
}
/// `http://host[:port]` or `https://host[:port]`, nothing else.
pub fn parse_url(url: &str) -> Result<ServerUrl, String> {
// http:// or https://, then a host, then optionally ":" and a port. The port: 1..=65535 written
// exactly as `port.to_string()` (so no "+1", no "080"). Default 443 for https, 80 for http. The
// host: 1..=253 bytes of a-z, 0-9, "." and "-", not starting or ending with "." or "-".
// Anything else, including a path, a user or an upper-case letter, is the one error message in
// the task.
todo!()
}
@@ -0,0 +1,96 @@
//! One turn on `loop.sock` and its answer in the thread (M4a spec, section 8). Typing is shown by
//! `serve`, which owns the WebSocket; this module only sends the turn and posts what comes back.
use std::os::unix::net::UnixStream;
use std::path::Path;
use proto::{
Envelope, ErrorCode, Message, PROTOCOL_VERSION, Turn, TurnEvent, WireError, read_frame,
write_frame,
};
use crate::mm::MmError;
use crate::mm::rest::Client;
use crate::sessions::Batch;
/// The longest reply we post, in characters (a post holds at most 16,383).
pub const MAX_POST: usize = 16_000;
pub const LOOP_DOWN: &str = "Boxmaker's loop is not running (see docs/runbook.md#loop-unavailable)";
pub const EMPTY_ANSWER: &str = "(the answer was empty)";
/// Somewhere to post: Mattermost, or a test's record.
pub trait Poster: Send + Sync {
fn post(&self, channel: &str, root: &str, text: &str) -> Result<(), MmError>;
}
impl Poster for Client {
fn post(&self, channel: &str, root: &str, text: &str) -> Result<(), MmError> {
self.create_post(channel, root, text).map(|_| ())
}
}
/// How a turn ended.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Outcome {
Answer(String),
Refused(WireError),
/// `loop.sock` could not be reached or closed early; why, for the log.
LoopDown(String),
}
pub fn approval_text(approval: u64) -> String {
format!(
"waiting for approval {approval}: approve or deny it with `bxctl` (Mattermost approvals arrive in M4b)"
)
}
/// "Error: <code>: <detail>"; the detail carries `loopd`'s runbook pointer when there is one.
pub fn error_text(error: &WireError) -> String {
// "Error: <code>: <detail>", where <code> is the snake_case name serde gives the ErrorCode
// (`serde_json::to_value(code)` is a JSON string, e.g. "no_such_session").
todo!()
}
/// An answer in posts of at most `MAX_POST` characters: each cut at the last newline before the
/// limit (the newline is dropped), or at the limit when there is none.
pub fn split_answer(text: &str) -> Vec<String> {
// Blank (only whitespace) -> [EMPTY_ANSWER]. Otherwise, while the rest is longer than MAX_POST
// *characters*: take the first MAX_POST characters; if they hold a newline past position 0, cut
// at the last one and drop that newline; else cut at MAX_POST characters. The last part is the
// rest.
todo!()
}
/// Send one turn and read it to its end; `on_event` sees every event.
fn one_turn(
socket: &Path,
batch: &Batch,
resume: bool,
on_event: &mut dyn FnMut(&TurnEvent),
) -> Outcome {
// 1. Connect to the socket (else LoopDown("cannot connect to <path>: <e>")).
// 2. `write_frame` one Envelope: v PROTOCOL_VERSION, id 1, final true, msg Turn (else
// LoopDown).
// 3. `read_frame` until the end: (id 1, not final, TurnEvent) -> on_event; (1, final, TurnDone)
// -> Answer with its content; (1, final, Error) -> Refused; a read error -> LoopDown("the
// turn ended early: <e>"); anything else -> LoopDown("an unexpected frame").
todo!()
}
/// A turn for a batch. A reply in a thread `loopd` does not know creates the session, as
/// `bxctl chat --session` does.
pub fn run_turn(socket: &Path, batch: &Batch, on_event: &mut dyn FnMut(&TurnEvent)) -> Outcome {
// `one_turn` with batch.resume. When that is Refused with NoSuchSession and batch.resume was
// true, one more `one_turn` with resume false. Otherwise the first outcome.
todo!()
}
/// Run a batch's turn and post what comes of it in its thread. A post that fails is logged.
pub fn deliver(poster: &dyn Poster, socket: &Path, batch: &Batch, log: &dyn Fn(&str)) {
// Post in the batch thread. An ApprovalPending event posts `approval_text(approval)` at once.
// Then: Answer -> every part of `split_answer`, in order; Refused -> `error_text`;
// LoopDown(why) -> log "gatewayd: <session>: <why>" and post LOOP_DOWN. A post that fails is
// logged, exactly "gatewayd: cannot post in <channel> (thread <root>): <error>", and the rest
// goes on.
todo!()
}
@@ -0,0 +1,127 @@
//! HTTP/1.1 over a connected stream: one request, one response, `Connection: close` (M4a spec,
//! section 5). The response head is read a byte at a time, so nothing past it is consumed: the
//! WebSocket handshake reads its frames after it from the same stream.
use std::io::{Read, Write};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
pub const MAX_HEAD: usize = 16 * 1024;
pub const MAX_BODY: usize = 4 * 1024 * 1024;
/// The longest a rate limit is waited out, whatever the server says.
pub const MAX_RATE_WAIT: Duration = Duration::from_secs(60);
#[derive(Debug)]
pub enum HttpError {
Io(std::io::Error),
/// The response is not HTTP/1.1 as we read it.
Protocol(String),
/// A head or body over its cap.
TooLarge(&'static str),
}
impl std::fmt::Display for HttpError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
HttpError::Io(e) => write!(f, "{e}"),
HttpError::Protocol(why) => write!(f, "bad HTTP response: {why}"),
HttpError::TooLarge(what) => write!(f, "the response {what} is too large"),
}
}
}
impl std::error::Error for HttpError {}
impl From<std::io::Error> for HttpError {
fn from(e: std::io::Error) -> Self {
HttpError::Io(e)
}
}
/// A status line and headers.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Head {
pub status: u16,
pub headers: Vec<(String, String)>,
}
impl Head {
/// The first header named `name`, compared case-insensitively.
pub fn header(&self, name: &str) -> Option<&str> {
// The value of the first header whose name matches, ignoring ASCII case.
todo!()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Response {
pub head: Head,
pub body: Vec<u8>,
}
/// Write one request. `host` is the `Host` header; `headers` come after it, then
/// `Content-Length` when there is a body, then `Connection: close`.
pub fn write_request(
stream: &mut dyn Write,
method: &str,
host: &str,
path: &str,
headers: &[(&str, &str)],
body: Option<&[u8]>,
) -> Result<(), HttpError> {
// Exactly: "<method> <path> HTTP/1.1\r\nHost: <host>\r\n", each header as "<k>: <v>\r\n",
// "Content-Length: <n>\r\n" when there is a body, "Connection: close\r\n\r\n", then the body.
// Flush.
todo!()
}
/// One request and its whole response.
pub fn request(
stream: &mut (impl Read + Write),
method: &str,
host: &str,
path: &str,
headers: &[(&str, &str)],
body: Option<&[u8]>,
) -> Result<Response, HttpError> {
// `write_request`, then `read_head`, then `read_body`.
todo!()
}
/// The status line and headers, up to and including the blank line, and not a byte more.
pub fn read_head(stream: &mut dyn Read) -> Result<Head, HttpError> {
// One byte at a time until CRLF CRLF, never more; over MAX_HEAD is TooLarge("head"); end of
// stream is Protocol; retry Interrupted. Then: UTF-8; status line "HTTP/1.1" or "HTTP/1.0", a
// 3-digit status in 100..=599; each header line "name: value" with a non-empty name without
// spaces, the value trimmed. Anything else is Protocol.
todo!()
}
/// The body after `head`: chunked, `Content-Length`, or to the end; at most `MAX_BODY`.
pub fn read_body(stream: &mut dyn Read, head: &Head) -> Result<Vec<u8>, HttpError> {
// Transfer-Encoding: chunked (any case) -> `read_chunked`. Else Content-Length: parse (else
// Protocol), over MAX_BODY is TooLarge("body"), then read_exact that many. Else read to the end
// through `take(MAX_BODY + 1)`; more than MAX_BODY is TooLarge("body").
todo!()
}
fn read_line(stream: &mut dyn Read, cap: usize) -> Result<String, HttpError> {
// Bytes up to CRLF (dropped), at most `cap` (else TooLarge("chunk header")); end of stream is
// Protocol.
todo!()
}
fn read_chunked(stream: &mut dyn Read) -> Result<Vec<u8>, HttpError> {
// Loop: a size line (hex, before any ";"), at most 1024 bytes. Size 0: read trailer lines (8
// KiB each) until an empty one, and return. Otherwise the size must fit in MAX_BODY minus what
// is already read (else TooLarge("body")), read it, then exactly CRLF (else Protocol).
todo!()
}
/// How long a 429 asks us to wait, from `X-Ratelimit-Reset`: a Unix time if it is one, else a
/// number of seconds; never more than `MAX_RATE_WAIT`. One second if the header is missing or bad.
pub fn rate_limit_wait(head: &Head, now: SystemTime) -> Duration {
// X-Ratelimit-Reset as u64: above 1_000_000_000 it is a Unix time (wait = it - now, at least 1
// s), otherwise seconds (at least 1). Missing or not a number: 1 s. Never more than
// MAX_RATE_WAIT.
todo!()
}
@@ -0,0 +1,37 @@
//! `gatewayd serve --config <path>`: the Mattermost channel. It loads its configuration and its
//! token, prepares its directory, then serves until it must stop (exit 1).
use std::os::unix::fs::DirBuilderExt;
use std::path::Path;
use std::process::ExitCode;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use gatewayd::config::{Config, MATTERMOST_TOKEN};
use gatewayd::secrets;
use gatewayd::serve::{START_FAILED, Tuning, run};
fn main() -> ExitCode {
// `args_os`: the config path need not be UTF-8, and `args` would panic on one that is not.
let args: Vec<std::ffi::OsString> = std::env::args_os().skip(1).collect();
let words: Vec<Option<&str>> = args.iter().map(|a| a.to_str()).collect();
match (words.as_slice(), args.get(2)) {
([Some("serve"), Some("--config"), _], Some(path)) => serve(Path::new(path)),
_ => {
eprintln!("usage: gatewayd serve --config <path>");
ExitCode::from(2)
}
}
}
fn serve(path: &Path) -> ExitCode {
// Each failure prints one line (plus its pointer) and returns ExitCode::from(1):
// 1. `Config::load`: "gatewayd: <error>\n<START_FAILED>". 2. `token_source`: "gatewayd: <path>:
// <why>\n<START_FAILED>".
// 3. `secrets::load(MATTERMOST_TOKEN, &source, &|k| std::env::var_os(k))`: "gatewayd: <error>"
// (it carries its pointer). Print the warning, if any, as it is. 4. Create the state file
// directory, recursive, 0700: "gatewayd: cannot prepare <dir>: <e>\n<START_FAILED>". 5.
// `run` with Tuning::default(), a log that prints each line to standard error, and a stop
// flag that is never set; print the Stop it returns.
todo!()
}
@@ -0,0 +1,149 @@
//! Mattermost's JSON, typed: the posts and users we read, the events that arrive over the
//! WebSocket, and the requests we send over it. Mattermost's JSON is not ours: unknown fields are
//! ignored, but every id we keep must be a valid id.
pub mod rest;
use std::time::Duration;
use serde::Deserialize;
use crate::config::valid_id;
/// At most this many changed posts come back from one `posts?since` call (Mattermost v11.11.0,
/// `SqlPostStore::GetPostsSince`); a full answer may have left some out.
pub const SINCE_LIMIT: usize = 1000;
#[derive(Debug)]
pub enum MmError {
/// No answer: connecting, TLS, or the HTTP exchange failed.
Net(String),
/// 401 or 403: the token is refused.
Auth(u16),
/// 429, still, after waiting as asked.
RateLimited(Duration),
/// Any other status that is not 2xx, with the start of the body.
Status(u16, String),
/// The answer is not the JSON we expect.
Json(String),
}
impl std::fmt::Display for MmError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
MmError::Net(why) => write!(f, "{why}"),
MmError::Auth(status) => write!(f, "Mattermost refused the token ({status})"),
MmError::RateLimited(wait) => {
write!(f, "rate limited; asked to wait {} s", wait.as_secs())
}
// Quoted: the body is the server's text, and must not forge a log line.
MmError::Status(status, body) => write!(f, "status {status}: {body:?}"),
MmError::Json(why) => write!(f, "unexpected JSON: {why}"),
}
}
}
impl std::error::Error for MmError {}
/// This bot, from `GET /users/me`.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub struct Me {
pub id: String,
pub username: String,
}
/// The fields of a post that `gatewayd` uses.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub struct Post {
pub id: String,
pub user_id: String,
pub channel_id: String,
#[serde(default)]
pub root_id: String,
#[serde(default)]
pub message: String,
pub create_at: i64,
#[serde(default)]
pub delete_at: i64,
/// Empty for a message a user wrote; anything else is a system message.
#[serde(default, rename = "type")]
pub kind: String,
}
impl Post {
/// Every id is a Mattermost id (the root may be empty): they end up in session ids and paths.
pub(crate) fn check(self) -> Result<Post, MmError> {
// id, user_id and channel_id must be `valid_id`, and root_id empty or `valid_id`; otherwise
// Json("a post with an invalid id: <id quoted with {:?}>").
todo!()
}
}
/// An event from the WebSocket.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Event {
Hello,
/// A new post, and the type of its channel: `D` direct, `G` group, `O` open, `P` private.
Posted {
post: Post,
channel_type: String,
},
/// Any other event, or a reply to one of our requests, by its name (empty for a reply).
Other(String),
}
#[derive(Deserialize)]
struct RawEvent {
#[serde(default)]
event: String,
#[serde(default)]
data: serde_json::Map<String, serde_json::Value>,
}
pub(crate) fn json<T: serde::de::DeserializeOwned>(bytes: &[u8]) -> Result<T, MmError> {
// `serde_json::from_slice`; its error becomes Json(e.to_string()).
todo!()
}
/// One WebSocket text message as an event. `posted` carries the post as a JSON **string**.
pub fn parse_event(text: &str) -> Result<Event, MmError> {
// Parse into RawEvent. "hello" -> Hello. "posted" -> data.post must be a JSON *string*; parse
// that string as a Post and `check` it (a missing or non-string post is Json). channel_type is
// data.channel_type when it is a string, else "". Any other event name -> Other(name) (a reply
// has no event: Other("")).
todo!()
}
/// The WebSocket request that shows this bot as typing in a thread (`parent` is the root).
pub fn typing(seq: u64, channel: &str, parent: &str) -> String {
// serde_json::json!({"action": "user_typing", "seq": seq, "data": {"channel_id": channel,
// "parent_id": parent}}) as a string.
todo!()
}
/// What a `posts?since` call gave back.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Since {
/// Posts created after the time, not deleted, oldest first.
pub posts: Vec<Post>,
/// Mattermost's limit was reached: some posts may be missing.
pub full: bool,
}
#[derive(Deserialize)]
struct PostList {
#[serde(default)]
order: Vec<String>,
#[serde(default)]
posts: std::collections::HashMap<String, Post>,
}
/// The body of `GET /channels/{id}/posts?since=<since>`. Only ids in `order` changed after
/// `since`; `posts` also holds the roots of their threads, which may be older. Edited and deleted
/// posts come back too: only new posts count.
pub fn since_list(body: &[u8], since: i64) -> Result<Since, MmError> {
// Parse a PostList. full = order.len() >= SINCE_LIMIT. For each id in `order` (never the keys
// of `posts`), skip it if it is not in `posts`; keep the post if create_at > since and
// delete_at == 0, after `check`. Sort by (create_at, id), remove repeated ids.
todo!()
}
@@ -0,0 +1,95 @@
//! Mattermost's REST calls, one connection per request, with the token in the `Authorization`
//! header. A 429 waits as asked; a 5xx is tried twice more; a 401 or 403 is `Auth` at once.
use std::time::{Duration, SystemTime};
use serde::Deserialize;
use crate::config::valid_id;
use crate::http::{self, Response, rate_limit_wait};
use crate::mm::{Me, MmError, Post, Since, json, since_list};
use crate::net::Connector;
use crate::secrets::Secret;
use crate::ws::conn::host_header;
/// The pause before trying a call again after a 5xx.
pub const RETRY_5XX: Duration = Duration::from_millis(500);
/// How many times a call is tried again after a 5xx, and waits after a 429.
pub const RETRIES: u32 = 2;
/// How much of an error body is kept for the message.
const BODY_KEPT: usize = 200;
pub struct Client {
connector: Connector,
token: Secret,
timeout: Duration,
}
#[derive(Deserialize)]
struct Channel {
id: String,
}
impl Client {
/// `timeout` bounds connecting and each read: no bytes for that long is an error.
pub fn new(connector: Connector, token: Secret, timeout: Duration) -> Client {
// Store the three.
todo!()
}
pub fn connector(&self) -> &Connector {
// The Connector.
todo!()
}
pub fn token(&self) -> &Secret {
// The Secret.
todo!()
}
fn once(&self, method: &str, path: &str, body: Option<&[u8]>) -> Result<Response, MmError> {
// 1. `self.connector.connect(self.timeout)`, then `set_read_timeout(Some(self.timeout))`.
// 2. Headers: Authorization "Bearer <token>" (the only use of `expose`), Accept
// "application/json", and Content-Type "application/json" when there is a body. Host is
// `host_header(self.connector.server())`.
// 3. `http::request`. Every error on the way is Net("<method> <path>: <error>").
todo!()
}
/// One call, tried again as the module comment says; the body of a 2xx answer.
fn call(&self, method: &str, path: &str, body: Option<&[u8]>) -> Result<Vec<u8>, MmError> {
// Loop over `once`: 2xx -> the body. 401 or 403 -> Auth(status) at once. 429 -> after
// RETRIES waits already, RateLimited(wait); else sleep `rate_limit_wait(&head,
// SystemTime::now())` and try again. 5xx -> up to RETRIES more tries, sleeping RETRY_5XX
// before each. Anything else -> Status(status, the first BODY_KEPT characters of the body,
// lossy UTF-8).
todo!()
}
/// `GET /api/v4/users/me`: who this token is.
pub fn me(&self) -> Result<Me, MmError> {
// GET /api/v4/users/me into Me; an id that is not `valid_id` or an empty username is Json.
todo!()
}
/// `POST /api/v4/posts`: a post in `channel`, in the thread of `root` (empty: top level).
pub fn create_post(&self, channel: &str, root: &str, message: &str) -> Result<Post, MmError> {
// POST /api/v4/posts with {"channel_id", "root_id", "message"}; the answer is a Post,
// `check`ed.
todo!()
}
/// `GET /api/v4/channels/{channel}/posts?since=<ms>`: the posts created after `since`.
pub fn posts_since(&self, channel: &str, since: i64) -> Result<Since, MmError> {
// A channel that is not `valid_id` is Json, and nothing is sent. Otherwise GET
// /api/v4/channels/<channel>/posts?since=<since>, through `since_list`.
todo!()
}
/// `POST /api/v4/channels/direct`: the id of the direct channel between two users.
pub fn direct_channel(&self, a: &str, b: &str) -> Result<String, MmError> {
// POST /api/v4/channels/direct with the JSON array [a, b]; the answer has an "id", which
// must be `valid_id` (else Json).
todo!()
}
}
@@ -0,0 +1,168 @@
//! A connection to the Mattermost server: TCP, or TCP with TLS through `rustls`, verified against
//! the host's trusted certificates plus an optional CA file (M4a spec, section 5). Verification
//! cannot be turned off.
use std::io::{Read, Write};
use std::net::{TcpStream, ToSocketAddrs};
use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
use rustls::pki_types::pem::PemObject;
use rustls::pki_types::{CertificateDer, ServerName};
use rustls::{ClientConfig, ClientConnection, RootCertStore, StreamOwned};
use crate::config::ServerUrl;
#[derive(Debug)]
pub enum NetError {
/// The CA file or the host's certificates could not be loaded.
Roots(String),
/// No address of the server accepted a connection.
Connect(String),
/// The TLS handshake failed: an unknown CA, a wrong name, an old protocol.
Tls(String),
}
impl std::fmt::Display for NetError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
NetError::Roots(why) => write!(f, "cannot load trusted certificates: {why}"),
NetError::Connect(why) => write!(f, "cannot connect: {why}"),
NetError::Tls(why) => write!(f, "TLS failed: {why}"),
}
}
}
impl std::error::Error for NetError {}
/// A connected stream, plain or TLS.
pub enum Stream {
Plain(TcpStream),
Tls(Box<StreamOwned<ClientConnection, TcpStream>>),
}
impl Stream {
/// The TCP socket underneath, for timeouts and shutdown.
pub fn tcp(&self) -> &TcpStream {
// The TcpStream: itself for Plain; `get_ref()` for Tls.
todo!()
}
pub fn set_read_timeout(&self, timeout: Option<Duration>) -> std::io::Result<()> {
// On `self.tcp()`.
todo!()
}
}
impl Read for Stream {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
// Forward to the inner stream for each variant.
todo!()
}
}
impl Write for Stream {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
// Forward to the inner stream for each variant.
todo!()
}
fn flush(&mut self) -> std::io::Result<()> {
// Forward to the inner stream for each variant.
todo!()
}
}
/// Makes connections to one server.
#[derive(Clone)]
pub struct Connector {
server: ServerUrl,
tls: Option<Arc<ClientConfig>>,
}
impl Connector {
/// For `https`, loads the host's trusted certificates and `ca_file`; an error in either is an
/// error here, before any connection.
pub fn new(server: ServerUrl, ca_file: Option<&Path>) -> Result<Connector, NetError> {
// For tls, `client_config(ca_file)?` in an Arc; for plain, None.
todo!()
}
pub fn server(&self) -> &ServerUrl {
// The ServerUrl.
todo!()
}
/// Connect, and for TLS complete the handshake, within `timeout` for each step.
pub fn connect(&self, timeout: Duration) -> Result<Stream, NetError> {
let addrs = (self.server.host.as_str(), self.server.port)
.to_socket_addrs()
.map_err(|e| NetError::Connect(format!("{}: {e}", self.server.host)))?;
let mut last = format!("{} has no address", self.server.host);
let mut tcp = None;
for addr in addrs {
match TcpStream::connect_timeout(&addr, timeout) {
Ok(s) => {
tcp = Some(s);
break;
}
Err(e) => last = format!("{addr}: {e}"),
}
}
let tcp = tcp.ok_or(NetError::Connect(last))?;
tcp.set_read_timeout(Some(timeout))
.map_err(|e| NetError::Connect(e.to_string()))?;
tcp.set_write_timeout(Some(timeout))
.map_err(|e| NetError::Connect(e.to_string()))?;
let _ = tcp.set_nodelay(true);
let Some(config) = &self.tls else {
return Ok(Stream::Plain(tcp));
};
let name = ServerName::try_from(self.server.host.clone())
.map_err(|e| NetError::Tls(e.to_string()))?;
let conn = ClientConnection::new(Arc::clone(config), name)
.map_err(|e| NetError::Tls(e.to_string()))?;
let mut stream = StreamOwned::new(conn, tcp);
while stream.conn.is_handshaking() {
stream
.conn
.complete_io(&mut stream.sock)
.map_err(|e| NetError::Tls(e.to_string()))?;
}
Ok(Stream::Tls(Box::new(stream)))
}
}
fn client_config(ca_file: Option<&Path>) -> Result<ClientConfig, NetError> {
let mut roots = RootCertStore::empty();
let native = rustls_native_certs::load_native_certs();
let (added, _ignored) = roots.add_parsable_certificates(native.certs);
if let Some(path) = ca_file {
let certs: Vec<CertificateDer<'static>> = CertificateDer::pem_file_iter(path)
.map_err(|e| NetError::Roots(format!("{}: {e}", path.display())))?
.collect::<Result<_, _>>()
.map_err(|e| NetError::Roots(format!("{}: {e}", path.display())))?;
if certs.is_empty() {
return Err(NetError::Roots(format!(
"{} holds no certificate",
path.display()
)));
}
for cert in certs {
roots
.add(cert)
.map_err(|e| NetError::Roots(format!("{}: {e}", path.display())))?;
}
} else if added == 0 {
return Err(NetError::Roots(
"the host has no trusted certificates and no ca_file is set".to_string(),
));
}
let provider = Arc::new(rustls::crypto::ring::default_provider());
let config = ClientConfig::builder_with_provider(provider)
.with_safe_default_protocol_versions()
.map_err(|e| NetError::Roots(e.to_string()))?
.with_root_certificates(roots)
.with_no_client_auth();
Ok(config)
}
@@ -0,0 +1,83 @@
//! The `SecretStore`: a secret from a systemd credential, an environment variable, or an owner-only
//! file (M4a spec, section 4; the brief after P15). A `Secret` cannot be printed.
use std::ffi::OsString;
use std::os::unix::fs::{MetadataExt, PermissionsExt};
use std::path::{Path, PathBuf};
use zeroize::Zeroizing;
use crate::config::SecretSource;
pub const RUNBOOK: &str = "see docs/runbook.md#secret-unavailable";
pub const RUNBOOK_FILE: &str = "see docs/runbook.md#secret-in-a-file";
/// A secret's text. No `Display`; `Debug` shows nothing of it; wiped when dropped.
pub struct Secret(Zeroizing<String>);
impl Secret {
pub fn new(text: String) -> Secret {
Secret(Zeroizing::new(text))
}
/// The text, for the one place that must send it.
pub fn expose(&self) -> &str {
&self.0
}
}
impl std::fmt::Debug for Secret {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("Secret(…)")
}
}
/// A loaded secret, and the warning to print for it, if any.
#[derive(Debug)]
pub struct Loaded {
pub secret: Secret,
pub warning: Option<String>,
}
#[derive(Debug)]
pub struct SecretError {
pub name: String,
pub why: String,
}
impl std::fmt::Display for SecretError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "secret {}: {}\n{RUNBOOK}", self.name, self.why)
}
}
impl std::error::Error for SecretError {}
/// Load secret `name` from `source`. `env` reads an environment variable (in `gatewayd`,
/// `std::env::var_os`); tests pass their own.
pub fn load(
name: &str,
source: &SecretSource,
env: &dyn Fn(&str) -> Option<OsString>,
) -> Result<Loaded, SecretError> {
// By source (spec section 4). Credential: read $CREDENTIALS_DIRECTORY/<name> (through `env`,
// not std::env); an unset variable, or a file that cannot be read, is an error. Env: the
// variable, UTF-8; unset is an error. File: `check_file` first, then read it, and set `warning`
// to the exact text in the task. Every value goes through `value`. Every error is a
// `SecretError` naming the secret, never the value.
todo!()
}
/// An owner-only regular file, not a link.
fn check_file(path: &Path) -> Result<(), String> {
// In this order, each its own error: not absolute; `symlink_metadata` fails; a symbolic link;
// not a regular file; owner uid differs from the uid of /proc/self; mode & 0o077 != 0.
todo!()
}
/// The text without one trailing newline; not empty; UTF-8.
fn value(bytes: Vec<u8>) -> Result<Secret, String> {
// UTF-8 (else an error), one trailing newline removed, not empty. Keep it in `Zeroizing`
// throughout.
todo!()
}
@@ -0,0 +1,78 @@
//! What the event loop does with a post, a finished turn, and the time between: routing, starting
//! turns, typing, and catching up after a gap.
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
use crate::deliver::{LOOP_DOWN, deliver};
use crate::mm::{Post, typing};
use crate::serve::{Gateway, Stop};
use crate::sessions::{BUSY, Batch, Ignored, Pushed, Route};
use crate::state::InFlight;
use crate::ws::WsError;
use crate::ws::conn::Ws;
/// Now, in Mattermost's milliseconds.
fn now_ms() -> i64 {
// Milliseconds since the Unix epoch as i64, with try_from (i64::MAX if it does not fit).
todo!()
}
impl Gateway {
/// Post in a thread; a failure is logged, not fatal.
pub(super) fn post(&self, channel: &str, root: &str, text: &str) {
// `self.client.create_post`; an error is logged, "gatewayd: cannot post in <channel>
// (thread <root>): <error>".
todo!()
}
/// Is this channel one whose posts `gatewayd` keeps track of?
fn tracked(&self, channel: &str, channel_type: &str) -> bool {
// channel_type "D", or the channel is in allow.channels.
todo!()
}
/// One new post, live or caught up.
pub(super) fn handle_post(&mut self, post: &Post, channel_type: &str) -> Result<(), Stop> {
// 1. Not tracked, or seen -> nothing. 2. `state.handled(...)?` before anything else. 3.
// Route it: NotAllowed -> log "gatewayd: ignored post <id> from <user>: not allowed"
// (never the message); other Ignore -> nothing; Reply -> post it; Queue -> join the
// thread when joins_thread, then push: Start -> `start`, Waiting -> nothing, Full ->
// post BUSY.
todo!()
}
/// Record the turn as in flight and run it on its own thread.
fn start(&mut self, batch: Batch) -> Result<(), Stop> {
// `state.start_turn` (session, channel, root). Spawn with std::thread::Builder: `deliver`
// with the client, the loop socket, the batch and the log, then send the session on
// done_tx. If spawning fails: log "gatewayd: cannot start a thread for <session>: <e>",
// post LOOP_DOWN in the thread, and send the session on done_tx.
todo!()
}
/// Turns that ended: out of flight, and the next batch of each session started.
pub(super) fn finished(&mut self) -> Result<(), Stop> {
// For each session on done_rx (try_recv, never blocking): `end_turn`, then `queues.finish`;
// a batch it returns is started.
todo!()
}
/// Show this bot as typing in every thread with a turn running.
pub(super) fn typing(&mut self, ws: &mut Ws) -> Result<(), WsError> {
// For each of `queues.threads()`: seq += 1, send `typing(seq, channel, root)` as text.
todo!()
}
/// Answer what arrived while `gatewayd` was away: the direct channel with each allowed user,
/// and each allowed channel. A channel seen for the first time starts from now.
pub(super) fn catch_up(&mut self) -> Result<(), Stop> {
// Channels: the direct channel with each allowed user ("D"; an error is logged "gatewayd:
// no direct channel with <user>: <e>" and skipped), then each allowed channel ("O"). For
// each: no mark -> `mark(channel, now_ms())` and skip; else `posts_since(channel, mark)`
// (an error is logged "gatewayd: cannot catch up <channel>: <e>" and skipped); when full,
// log "gatewayd: <channel>: too many posts to catch up; some may be missed"; `handle_post`
// each post in order.
todo!()
}
}
@@ -0,0 +1,232 @@
//! Startup, the event loop and reconnecting (M4a spec, section 9). `run` returns only when
//! `gatewayd` must stop: a refused token, a state file it cannot keep, or a stop asked by a test.
mod handle;
use std::fs::File;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::{self, Receiver, Sender};
use std::time::{Duration, Instant};
use proto::SessionId;
use crate::config::Config;
use crate::mm::rest::Client;
use crate::mm::{Event, Me, MmError, parse_event};
use crate::net::Connector;
use crate::secrets::Secret;
use crate::sessions::{Queues, Router};
use crate::state::{State, StateError};
use crate::ws::conn::{Timing, Ws};
pub const UNREACHABLE: &str = "see docs/runbook.md#mattermost-unreachable";
pub const AUTH_FAILED: &str = "see docs/runbook.md#mattermost-auth-failed";
pub const START_FAILED: &str = "see docs/runbook.md#gatewayd-start-failed";
pub const INTERRUPTED: &str =
"interrupted: gatewayd restarted before the answer arrived; ask again";
/// A log line: `stderr` in `main`, a record in tests.
pub type Log = Arc<dyn Fn(&str) + Send + Sync>;
/// Why `run` returned.
#[derive(Debug)]
pub enum Stop {
/// Mattermost refused the token (401 or 403).
Auth(u16),
State(StateError),
/// Something `gatewayd` needs at start is missing; the message names it.
Start(String),
/// The stop flag was set.
Asked,
}
impl std::fmt::Display for Stop {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Stop::Auth(status) => {
write!(
f,
"gatewayd: Mattermost refused the token ({status})\n{AUTH_FAILED}"
)
}
Stop::State(e) => write!(f, "gatewayd: {e}"),
Stop::Start(why) => write!(f, "gatewayd: {why}\n{START_FAILED}"),
Stop::Asked => write!(f, "gatewayd: stopped"),
}
}
}
impl From<StateError> for Stop {
fn from(e: StateError) -> Stop {
// Stop::State(e).
todo!()
}
}
/// Timings a test shortens.
#[derive(Debug, Clone)]
pub struct Tuning {
/// The waits between connection attempts; the last repeats.
pub backoff: Vec<Duration>,
/// How long one wait for a WebSocket message lasts, at most.
pub poll: Duration,
/// Connecting and each REST read.
pub rest_timeout: Duration,
}
impl Default for Tuning {
fn default() -> Self {
let secs = [1, 2, 5, 10, 30].map(Duration::from_secs);
Tuning {
backoff: secs.to_vec(),
poll: Duration::from_millis(200),
rest_timeout: Duration::from_secs(30),
}
}
}
/// Everything the event loop works with.
pub(crate) struct Gateway {
config: Config,
client: Arc<Client>,
router: Router,
me: Me,
state: State,
queues: Queues,
loop_socket: PathBuf,
done_tx: Sender<SessionId>,
done_rx: Receiver<SessionId>,
log: Log,
/// The typing requests' sequence number.
seq: u64,
/// The turns a restart cut off have been answered.
restarted: bool,
}
/// The wait before attempt `n` (from 0) after a loss.
pub fn backoff(tuning: &Tuning, n: usize) -> Duration {
// tuning.backoff[n], or its last entry when n is past the end (30 s if the list is empty). No
// indexing.
todo!()
}
/// Sleep for `d`, in short steps, unless `stop` is set.
fn sleep_unless(stop: &AtomicBool, d: Duration) {
// Sleep in steps of at most 20 ms until `d` has passed or `stop` is set.
todo!()
}
/// Run `gatewayd` with its token, until it must stop.
pub fn run(config: Config, token: Secret, tuning: Tuning, log: Log, stop: &AtomicBool) -> Stop {
let server = match config.server() {
Ok(s) => s,
Err(why) => return Stop::Start(why),
};
let connector = match Connector::new(server, config.mattermost.ca_file.as_deref()) {
Ok(c) => c,
Err(e) => return Stop::Start(e.to_string()),
};
let state = match State::load(&config.state_path()) {
Ok(s) => s,
Err(e) => return Stop::State(e),
};
let client = Arc::new(Client::new(connector, token, tuning.rest_timeout));
let mut g = Gateway::new(config, client, state, log);
let mut failures = 0;
loop {
if stop.load(Ordering::SeqCst) {
return Stop::Asked;
}
let stopped = match connect(&g.config, &g.client, &tuning) {
Ok((me, mut ws)) => {
failures = 0;
g.connected(me)
.and_then(|()| g.catch_up())
.and_then(|()| g.event_loop(&mut ws, &tuning, stop))
}
Err(MmError::Auth(status)) => Err(Stop::Auth(status)),
Err(e) => {
let wait = backoff(&tuning, failures);
failures += 1;
(g.log)(&format!(
"gatewayd: cannot reach {}: {e}; trying again in {} s\n{UNREACHABLE}",
g.config.mattermost.url,
wait.as_secs()
));
sleep_unless(stop, wait);
Ok(())
}
};
if let Err(stop) = stopped {
return stop;
}
}
}
/// Who we are, and a WebSocket that has said hello.
fn connect(config: &Config, client: &Client, tuning: &Tuning) -> Result<(Me, Ws), MmError> {
// 1. `client.me()?`. 2. Timing from limits (ping_every_ms, dead_after_ms). 3. Open /dev/urandom
// (an error is Net("/dev/urandom: <e>")). 4. `Ws::open(client.connector(),
// client.token().expose(), timing, ..)`.
// 5. Poll with tuning.poll until a text that `parse_event`s to Hello, for at most dead_after.
// Every WebSocket error, and no hello in time, is Net("websocket: <why>").
todo!()
}
impl Gateway {
fn new(config: Config, client: Arc<Client>, state: State, log: Log) -> Gateway {
// A done channel, a Router with empty ids (set on connect), an empty Me, loop_socket from
// config, Queues with limit limits.queue (usize::try_from), seq 0, restarted false.
todo!()
}
/// A connection is up: say so, route as this user, and on the first one, answer the turns a
/// restart cut off.
fn connected(&mut self, me: Me) -> Result<(), Stop> {
// Log exactly "gatewayd: connected to <url> as <username>". A new Router from me and the
// allow lists; store me. The first time only (`restarted`): for each turn `take_in_flight`
// gives back, post INTERRUPTED in its channel and root. A later reconnect must not: those
// turns are still running.
todo!()
}
/// Read events until the connection is lost (`Ok`) or `gatewayd` must stop.
fn event_loop(&mut self, ws: &mut Ws, tuning: &Tuning, stop: &AtomicBool) -> Result<(), Stop> {
let typing_every = Duration::from_millis(self.config.limits.typing_every_ms);
let mut last_typing = Instant::now();
loop {
if stop.load(Ordering::SeqCst) {
return Err(Stop::Asked);
}
self.finished()?;
if last_typing.elapsed() >= typing_every {
last_typing = Instant::now();
if let Err(e) = self.typing(ws) {
(self.log)(&format!(
"gatewayd: lost the connection: {e}\n{UNREACHABLE}"
));
return Ok(());
}
}
let text = match ws.poll(tuning.poll.min(typing_every)) {
Ok(Some(text)) => text,
Ok(None) => continue,
Err(e) => {
(self.log)(&format!(
"gatewayd: lost the connection: {e}\n{UNREACHABLE}"
));
return Ok(());
}
};
match parse_event(&text) {
Ok(Event::Posted { post, channel_type }) => {
self.handle_post(&post, &channel_type)?
}
Ok(_) => {}
Err(e) => (self.log)(&format!("gatewayd: ignored an event: {e}")),
}
}
}
}
@@ -0,0 +1,162 @@
//! Which posts `gatewayd` acts on, which session each belongs to, commands, and the queue of
//! messages per session (M4a spec, section 7). Pure: the state file and the network are elsewhere.
use std::collections::{BTreeSet, HashMap};
use proto::SessionId;
use crate::mm::Post;
pub const M4B_COMMAND: &str = "approvals over Mattermost arrive in M4b; use `bxctl approvals`";
pub const UNKNOWN_COMMAND: &str = "unknown command; the commands are !approve and !deny";
pub const BUSY: &str = "busy: too many messages are waiting in this conversation";
/// Names that name nobody: every agent in the channel would answer them.
const EVERYONE: [&str; 3] = ["channel", "here", "all"];
/// Why a post was not acted on. Only `NotAllowed` is logged, by user id and post id.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Ignored {
Own,
System,
NotAllowed,
NotForUs,
}
/// Where an answer goes: a channel, and the root of the thread in it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Thread {
pub channel: String,
pub root: String,
}
/// A message for a session.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Message {
pub session: SessionId,
pub thread: Thread,
/// A reply in a thread: the session should exist already.
pub resume: bool,
pub text: String,
/// A thread in a channel or group message that this Boxmaker now takes part in.
pub joins_thread: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Route {
Ignore(Ignored),
/// Answer in the thread without a turn (a command).
Reply {
thread: Thread,
text: String,
},
Queue(Message),
}
pub struct Router {
me_id: String,
me_name: String,
users: BTreeSet<String>,
channels: BTreeSet<String>,
}
/// Every `@name` in a message, lower-cased: `a-z`, `0-9`, `.`, `-` and `_` after an `@`, without
/// trailing dots.
pub fn named(message: &str) -> Vec<String> {
// Find each "@". The name after it is the longest run of ASCII letters, digits, ".", "-" and
// "_", with trailing "." removed and lower-cased. Skip empty names. Continue after the name.
todo!()
}
impl Router {
pub fn new(me_id: &str, me_name: &str, users: &[String], channels: &[String]) -> Router {
// Store the ids, the username lower-cased, and the two lists as sets.
todo!()
}
/// Is a post in a channel or group message for this Boxmaker? `known` says whether it has a
/// session for a thread root.
fn for_us(&self, post: &Post, known: &dyn Fn(&str) -> bool) -> bool {
// Named this bot -> true. Otherwise true only for a reply (root_id not empty) in a known
// thread (`known(root_id)`) that names nobody but channel, here or all.
todo!()
}
/// What to do with a new post (a post seen before is dropped by the caller first).
pub fn route(&self, post: &Post, channel_type: &str, known: &dyn Fn(&str) -> bool) -> Route {
// Section 7 of the spec, in this order: own post -> Ignore(Own); kind not empty ->
// Ignore(System); user not allowed -> Ignore(NotAllowed); then the channel: "D" is always
// ours; "O", "P" or "G" is ours when its id is allowed and `for_us`; anything else ->
// Ignore(NotForUs). The thread root is root_id, or the post id when root_id is empty. Then
// commands: "!!..." drops one "!" and goes on as a message; "!" then a first word approve
// or deny -> Reply M4B_COMMAND; any other "!" -> Reply UNKNOWN_COMMAND. Then Queue: session
// "mm-<root>" (if SessionId::new fails, Ignore(NotForUs)), resume = root_id not empty,
// joins_thread = not "D".
todo!()
}
}
/// A turn to send: every message that was waiting, joined with a blank line.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Batch {
pub session: SessionId,
pub thread: Thread,
pub resume: bool,
pub text: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Pushed {
/// No turn was running: send this one now.
Start(Batch),
/// A turn is running; the message waits for the next.
Waiting,
/// Too many are waiting: the message is dropped, answer `BUSY`.
Full(Thread),
}
struct Pending {
thread: Thread,
resume: bool,
waiting: Vec<String>,
}
/// The sessions with a turn running, and the messages waiting for each.
pub struct Queues {
limit: usize,
running: HashMap<SessionId, Pending>,
}
impl Queues {
/// `limit` is the most messages that may wait per session.
pub fn new(limit: usize) -> Queues {
// An empty map.
todo!()
}
pub fn push(&mut self, message: Message) -> Pushed {
// If the session is running: with `limit` messages already waiting -> Full(thread); else
// add the text to waiting -> Waiting. Otherwise insert it as running (its later batches
// resume: true) and return Start with this message alone.
todo!()
}
/// A session's turn ended: the next batch, or `None`, and the session is no longer running.
pub fn finish(&mut self, session: &SessionId) -> Option<Batch> {
// Not running -> None. Nothing waiting -> remove it, None. Otherwise take every waiting
// text, joined with "\n\n", as the next Batch (it stays running).
todo!()
}
/// How many sessions have a turn running.
pub fn running(&self) -> usize {
// How many sessions are running.
todo!()
}
/// The threads with a turn running, for showing this bot as typing in them.
pub fn threads(&self) -> Vec<Thread> {
// The thread of every running session.
todo!()
}
}
@@ -0,0 +1,158 @@
//! `<home>/gateway/state.json`: what `gatewayd` has handled, the threads it takes part in, and the
//! turns in flight (M4a spec, section 9). Written atomically after every change. A file that
//! cannot be read, parsed or written stops `gatewayd`: guessing would answer posts twice.
use std::collections::BTreeMap;
use std::fs;
use std::io::{self, Write};
use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt};
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::config::valid_id;
pub const RECENT_KEPT: usize = 500;
pub const THREADS_KEPT: usize = 5000;
pub const RUNBOOK: &str = "see docs/runbook.md#gateway-state-damaged";
#[derive(Debug)]
pub enum StateError {
Read(PathBuf, String),
Write(PathBuf, io::Error),
}
impl std::fmt::Display for StateError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
StateError::Read(path, why) => write!(f, "{}: {why}\n{RUNBOOK}", path.display()),
StateError::Write(path, err) => {
write!(f, "{}: cannot write: {err}\n{RUNBOOK}", path.display())
}
}
}
}
impl std::error::Error for StateError {}
/// A turn sent to `loopd` and not yet answered.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct InFlight {
pub session: String,
pub channel: String,
pub root: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct StateFile {
channels: BTreeMap<String, i64>,
recent: Vec<String>,
threads: Vec<String>,
in_flight: Vec<InFlight>,
}
impl StateFile {
/// Every id is a Mattermost id; a session is `mm-<id>`.
fn problem(&self) -> Option<String> {
// Every key of channels, every entry of recent and threads must be `valid_id` ("not a
// Mattermost id: <id with {:?}>"). Each in_flight entry: session is "mm-" + a valid id,
// channel and root valid ids ("a turn in flight is not valid: <session with {:?}>"). None
// when all are good.
todo!()
}
}
pub struct State {
path: PathBuf,
file: StateFile,
}
impl State {
/// Read the state; a missing file is a first start.
pub fn load(path: &Path) -> Result<State, StateError> {
// Read the file. Missing (NotFound only) -> an empty StateFile. Any other read error, a
// parse error (serde_json), or `problem()` -> Read(path, why).
todo!()
}
/// Write the state to its path atomically, in six steps; an error leaves the old file.
fn save(&self) -> Result<(), StateError> {
// `persist`, its error as Write(path, error).
todo!()
}
fn persist(&self) -> io::Result<()> {
// The six steps of brokerd/src/state.rs, persist: 1. the parent directory, recursive, 0700;
// 2. serde_json::to_string plus "\n"; 3. "<path>.tmp" (with_extension("json.tmp")), create
// + truncate, mode 0600; 4. write_all and sync_all; 5. rename over the path; 6. open the
// directory and sync_all.
todo!()
}
/// Was this post handled already?
pub fn seen(&self, post_id: &str) -> bool {
// Is the id in recent?
todo!()
}
/// A post was handled (acted on or ignored): remember its id and move its channel's mark.
pub fn handled(
&mut self,
post_id: &str,
channel: &str,
create_at: i64,
) -> Result<(), StateError> {
// If not seen: push the id to recent, then drop the oldest beyond RECENT_KEPT. The channel
// mark becomes the larger of its old value and create_at (a new channel starts at
// create_at). Save.
todo!()
}
/// The `create_at` of the last post handled in a channel, if any.
pub fn since(&self, channel: &str) -> Option<i64> {
// The mark of the channel.
todo!()
}
/// The channels with a mark, for catching up.
pub fn channels(&self) -> Vec<String> {
// The channel ids that have a mark.
todo!()
}
/// Start catching up a channel from `at` (milliseconds), if it has no mark yet.
pub fn mark(&mut self, channel: &str, at: i64) -> Result<(), StateError> {
// A channel that has a mark is left alone (nothing saved). Otherwise set it to `at` and
// save.
todo!()
}
pub fn knows_thread(&self, root: &str) -> bool {
// Is the root in threads?
todo!()
}
/// This Boxmaker takes part in a thread; the newest `THREADS_KEPT` are kept.
pub fn join_thread(&mut self, root: &str) -> Result<(), StateError> {
// Known -> nothing. Else push it, drop the oldest beyond THREADS_KEPT, save.
todo!()
}
pub fn start_turn(&mut self, turn: InFlight) -> Result<(), StateError> {
// Remove any entry of the same session, push this one, save.
todo!()
}
pub fn end_turn(&mut self, session: &str) -> Result<(), StateError> {
// Remove the entries of the session, save.
todo!()
}
/// The turns left in flight by the last run, removed from the state.
pub fn take_in_flight(&mut self) -> Result<Vec<InFlight>, StateError> {
// Take the whole list out (std::mem::take); save only when it was not empty.
todo!()
}
}
@@ -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)
}
}
@@ -0,0 +1,246 @@
//! `gatewayd.toml` (M4a spec, section 3). Do not edit.
#[path = "support/tmp.rs"]
mod tmp;
use std::path::PathBuf;
use gatewayd::config::{Config, ConfigError, SecretSource, ServerUrl, parse_url, valid_id};
use tmp::TempDir;
const OWNER: &str = "abcdefghijklmnopqrstuvwxyz";
const CHANNEL: &str = "0123456789abcdefghijklmnop";
fn minimal() -> String {
format!(
"[mattermost]\nurl = \"https://straylight.scylla-hammerhead.ts.net\"\n\
[secrets.mattermost_token]\ncredential = \"mattermost-token\"\n\
[allow]\nusers = [\"{OWNER}\"]\n"
)
}
fn load(text: &str) -> Result<Config, ConfigError> {
let dir = TempDir::new("cfg");
let path = dir.write("gatewayd.toml", text);
Config::load(&path)
}
fn invalid(text: &str) -> String {
match load(text) {
Err(ConfigError::Invalid(_, why)) => why,
other => panic!("expected Invalid for {text:?}, got {other:?}"),
}
}
#[test]
fn a_minimal_config_gets_every_default() {
let c = load(&minimal()).unwrap();
assert_eq!(
c.server().unwrap(),
ServerUrl {
tls: true,
host: "straylight.scylla-hammerhead.ts.net".to_string(),
port: 443
}
);
assert_eq!(c.mattermost.ca_file, None);
assert_eq!(
c.token_source().unwrap(),
SecretSource::Credential("mattermost-token".to_string())
);
assert_eq!(c.allow.users, vec![OWNER.to_string()]);
assert!(c.allow.channels.is_empty());
assert_eq!((c.limits.queue, c.limits.typing_every_ms), (20, 3_000));
assert_eq!(
(c.limits.ping_every_ms, c.limits.dead_after_ms),
(30_000, 60_000)
);
let home = std::env::var_os("BOXMAKER_HOME")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("/var/lib/boxmaker"));
assert_eq!(c.loop_socket(), home.join("run/loop/loop.sock"));
assert_eq!(c.state_path(), home.join("gateway/state.json"));
}
#[test]
fn every_value_can_be_set() {
let text = format!(
"[mattermost]\nurl = \"http://127.0.0.1:8065\"\nca_file = \"/etc/boxmaker/ca.pem\"\n\
[secrets.mattermost_token]\nfile = \"/home/k/.config/boxmaker/token\"\n\
[allow]\nusers = [\"{OWNER}\"]\nchannels = [\"{CHANNEL}\"]\n\
[loop]\nsocket = \"/run/l.sock\"\n[paths]\nhome = \"/h\"\n\
[limits]\nqueue = 5\ntyping_every_ms = 1\nping_every_ms = 2\ndead_after_ms = 3\n"
);
let c = load(&text).unwrap();
assert_eq!(
c.server().unwrap(),
ServerUrl {
tls: false,
host: "127.0.0.1".to_string(),
port: 8065
}
);
assert_eq!(
c.mattermost.ca_file,
Some(PathBuf::from("/etc/boxmaker/ca.pem"))
);
assert_eq!(
c.token_source().unwrap(),
SecretSource::File(PathBuf::from("/home/k/.config/boxmaker/token"))
);
assert_eq!(c.allow.channels, vec![CHANNEL.to_string()]);
assert_eq!(c.loop_socket(), PathBuf::from("/run/l.sock"));
assert_eq!(c.state_path(), PathBuf::from("/h/gateway/state.json"));
assert_eq!(
(
c.limits.queue,
c.limits.typing_every_ms,
c.limits.ping_every_ms,
c.limits.dead_after_ms
),
(5, 1, 2, 3)
);
}
#[test]
fn an_env_secret() {
let text = minimal().replace(
"credential = \"mattermost-token\"",
"env = \"BOXMAKER_MM_TOKEN\"",
);
assert_eq!(
load(&text).unwrap().token_source().unwrap(),
SecretSource::Env("BOXMAKER_MM_TOKEN".to_string())
);
}
#[test]
fn urls() {
for (url, tls, host, port) in [
("https://a.example", true, "a.example", 443),
("https://a.example:8443", true, "a.example", 8443),
("http://localhost", false, "localhost", 80),
("http://127.0.0.1:8065", false, "127.0.0.1", 8065),
] {
assert_eq!(
parse_url(url),
Ok(ServerUrl {
tls,
host: host.to_string(),
port
}),
"{url}"
);
}
for url in [
"",
"a.example",
"ftp://a.example",
"https://",
"https://a.example/",
"https://a.example/api",
"https://A.example",
"https://a.example:0",
"https://a.example:65536",
"https://a.example:0443",
"https://a.example:",
"https://user@a.example",
"https://a.example?x",
"https://.a.example",
"https://a.example.",
"https://[::1]:443",
"https:// a.example",
] {
assert!(parse_url(url).is_err(), "{url:?} must be refused");
}
}
#[test]
fn ids() {
assert!(valid_id(OWNER));
assert!(valid_id(CHANNEL));
for id in [
"",
"abc",
"abcdefghijklmnopqrstuvwxyZ",
"abcdefghijklmnopqrstuvwxy-",
"abcdefghijklmnopqrstuvwxyza",
] {
assert!(!valid_id(id), "{id:?}");
}
}
#[test]
fn bad_values_are_named() {
let token = "credential = \"mattermost-token\"";
let cases: Vec<(String, &str)> = vec![
(
minimal().replace(
"https://straylight.scylla-hammerhead.ts.net",
"https://x.example/path",
),
"url",
),
(
minimal()
.replace(
"[secrets.mattermost_token]",
"[mattermost2]\n[secrets.other]",
)
.replace("[mattermost2]\n", ""),
"mattermost_token",
),
(
minimal().replace(token, "credential = \"a b\""),
"credential",
),
(minimal().replace(token, "env = \"lower\""), "env"),
(
minimal().replace(token, "file = \"relative/token\""),
"absolute",
),
(
minimal().replace(token, "credential = \"x\"\nenv = \"Y\""),
"exactly one",
),
(minimal().replace(token, ""), "exactly one"),
(
minimal().replace(&format!("[\"{OWNER}\"]"), "[]"),
"users is empty",
),
(minimal().replace(OWNER, "tooshort"), "not a Mattermost id"),
(
format!("{}channels = [\"NOTANID\"]\n", minimal()),
"not a Mattermost id",
),
(
minimal().replace("[mattermost]\n", "[mattermost]\nca_file = \"ca.pem\"\n"),
"ca_file",
),
(format!("{}[limits]\nqueue = 0\n", minimal()), "queue"),
(
format!("{}[limits]\ndead_after_ms = 0\n", minimal()),
"dead_after_ms",
),
];
for (text, word) in cases {
let why = invalid(&text);
assert!(why.contains(word), "{word}: {why}");
}
}
#[test]
fn unknown_keys_and_missing_tables_are_parse_errors() {
for text in [
format!("{}[allow2]\n", minimal()),
minimal().replace("[mattermost]\n", "[mattermost]\nproxy = \"x\"\n"),
minimal().replace(
"credential = \"mattermost-token\"",
"credential = \"t\"\nkeyring = \"x\"",
),
minimal().replace(&format!("[allow]\nusers = [\"{OWNER}\"]\n"), ""),
format!("{}[limits]\nqueue = -1\n", minimal()),
] {
assert!(matches!(load(&text), Err(ConfigError::Parse(..))), "{text}");
}
}
@@ -0,0 +1,304 @@
//! A turn on `loop.sock` and its answer in the thread, against a fake `loopd`: the answer, long
//! answers, approvals, errors, an unknown session, and a loop that is not there (M4a spec, section
//! 8). Do not edit.
#[path = "support/fake_loop.rs"]
mod fake_loop;
#[path = "support/tmp.rs"]
mod tmp;
use std::sync::Mutex;
use std::time::Duration;
use fake_loop::{Reply, done, error, event, serve_loop};
use gatewayd::deliver::{EMPTY_ANSWER, LOOP_DOWN, MAX_POST, Poster, deliver, split_answer};
use gatewayd::mm::MmError;
use gatewayd::sessions::{Batch, Thread};
use proto::{DataClass, ErrorCode, SessionId, Timestamp, TurnEvent};
use tmp::TempDir;
const DM: &str = "d0000000000000000000000000";
const ROOT: &str = "r0000000000000000000000000";
#[derive(Default)]
struct Record {
posts: Mutex<Vec<(String, String, String)>>,
fail: bool,
}
impl Poster for Record {
fn post(&self, channel: &str, root: &str, text: &str) -> Result<(), MmError> {
if self.fail {
return Err(MmError::Status(500, "down".to_string()));
}
self.posts
.lock()
.unwrap()
.push((channel.to_string(), root.to_string(), text.to_string()));
Ok(())
}
}
impl Record {
fn texts(&self) -> Vec<String> {
let posts = self.posts.lock().unwrap();
assert!(
posts.iter().all(|(c, r, _)| c == DM && r == ROOT),
"every post in the thread"
);
posts.iter().map(|(_, _, t)| t.clone()).collect()
}
}
fn batch(resume: bool, text: &str) -> Batch {
Batch {
session: SessionId::new(&format!("mm-{ROOT}")).unwrap(),
thread: Thread {
channel: DM.to_string(),
root: ROOT.to_string(),
},
resume,
text: text.to_string(),
}
}
fn run(dir: &TempDir, poster: &Record, batch: &Batch) -> Vec<String> {
let log = Mutex::new(Vec::new());
deliver(poster, &dir.path().join("loop.sock"), batch, &|line| {
log.lock().unwrap().push(line.to_string())
});
log.into_inner().unwrap()
}
#[test]
fn the_answer_is_posted_and_nothing_else() {
let dir = TempDir::new("deliver-answer");
let turns = serve_loop(&dir.path().join("loop.sock"), |_, _| {
vec![
event(TurnEvent::Progress {
total: 10,
cache: 0,
processed: 10,
}),
event(TurnEvent::Reasoning {
text: "private thoughts".to_string(),
}),
event(TurnEvent::ToolCallStarted {
name: "read_file".to_string(),
}),
event(TurnEvent::ToolResult {
name: "read_file".to_string(),
class: DataClass::Private,
truncated: false,
}),
event(TurnEvent::Content {
text: "The ans".to_string(),
}),
done("The answer."),
]
});
let poster = Record::default();
let log = run(&dir, &poster, &batch(true, "one\n\ntwo"));
assert_eq!(poster.texts(), ["The answer."]);
assert!(log.is_empty(), "{log:?}");
let turn = turns.recv_timeout(Duration::from_secs(5)).unwrap();
assert_eq!(
(turn.session.as_str(), turn.content.as_str(), turn.resume),
(format!("mm-{ROOT}").as_str(), "one\n\ntwo", true)
);
}
#[test]
fn an_approval_is_announced_once_before_the_answer() {
let dir = TempDir::new("deliver-approval");
let expires = Timestamp::from_unix_millis(1_758_650_000_000).unwrap();
let _turns = serve_loop(&dir.path().join("loop.sock"), move |_, _| {
vec![
event(TurnEvent::ApprovalPending {
approval: 42,
tool: "shell".to_string(),
expires,
}),
done("done"),
]
});
let poster = Record::default();
run(&dir, &poster, &batch(false, "go"));
assert_eq!(
poster.texts(),
[
"waiting for approval 42: approve or deny it with `bxctl` (Mattermost approvals arrive in M4b)",
"done"
]
);
}
#[test]
fn errors_are_posted_with_their_code() {
let dir = TempDir::new("deliver-error");
let _turns = serve_loop(&dir.path().join("loop.sock"), |_, _| {
vec![error(
ErrorCode::Inference,
"the model server failed\nsee docs/runbook.md#loopd-selftest-failed",
)]
});
let poster = Record::default();
run(&dir, &poster, &batch(false, "go"));
assert_eq!(
poster.texts(),
["Error: inference: the model server failed\nsee docs/runbook.md#loopd-selftest-failed"]
);
}
#[test]
fn a_reply_in_a_thread_loopd_does_not_know_creates_the_session() {
let dir = TempDir::new("deliver-unknown");
let turns = serve_loop(&dir.path().join("loop.sock"), |n, _| {
if n == 0 {
vec![error(ErrorCode::NoSuchSession, "no such session")]
} else {
vec![done("hello")]
}
});
let poster = Record::default();
run(&dir, &poster, &batch(true, "hi"));
assert_eq!(poster.texts(), ["hello"]);
let first = turns.recv_timeout(Duration::from_secs(5)).unwrap();
let second = turns.recv_timeout(Duration::from_secs(5)).unwrap();
assert_eq!(
(first.resume, second.resume, second.content.as_str()),
(true, false, "hi")
);
}
#[test]
fn a_new_session_is_not_retried() {
let dir = TempDir::new("deliver-noretry");
let turns = serve_loop(&dir.path().join("loop.sock"), |_, _| {
vec![error(ErrorCode::NoSuchSession, "odd")]
});
let poster = Record::default();
run(&dir, &poster, &batch(false, "hi"));
assert_eq!(poster.texts(), ["Error: no_such_session: odd"]);
assert!(turns.recv_timeout(Duration::from_secs(5)).is_ok());
assert!(
turns.recv_timeout(Duration::from_millis(200)).is_err(),
"one turn only"
);
}
#[test]
fn a_loop_that_is_not_there_or_goes_away() {
let dir = TempDir::new("deliver-down");
let poster = Record::default();
let log = run(&dir, &poster, &batch(false, "hi"));
assert_eq!(poster.texts(), [LOOP_DOWN]);
assert_eq!(log.len(), 1, "{log:?}");
assert!(
log[0].starts_with(&format!("gatewayd: mm-{ROOT}: cannot connect to ")),
"{log:?}"
);
for (n, replies) in [
vec![
event(TurnEvent::Content {
text: "x".to_string(),
}),
Reply::Close,
],
vec![Reply::Bytes(b"\x00\x00\x00\x05{bad}".to_vec())],
vec![
Reply::Frame(proto::Envelope {
v: 1,
id: 2,
r#final: true,
msg: proto::Message::Ok(proto::Empty {}),
}),
done("late"),
],
]
.into_iter()
.enumerate()
{
let dir = TempDir::new(&format!("deliver-early-{n}"));
let replies = Mutex::new(Some(replies));
let _turns = serve_loop(&dir.path().join("loop.sock"), move |_, _| {
replies.lock().unwrap().take().unwrap_or_default()
});
let poster = Record::default();
let log = run(&dir, &poster, &batch(false, "hi"));
assert_eq!(poster.texts(), [LOOP_DOWN], "case {n}");
assert_eq!(log.len(), 1, "case {n}: {log:?}");
}
}
#[test]
fn a_post_that_fails_is_logged() {
let dir = TempDir::new("deliver-postfail");
let _turns = serve_loop(&dir.path().join("loop.sock"), |_, _| vec![done("lost")]);
let poster = Record {
fail: true,
..Record::default()
};
let log = run(&dir, &poster, &batch(false, "hi"));
assert_eq!(
log,
[format!(
"gatewayd: cannot post in {DM} (thread {ROOT}): status 500: \"down\""
)]
);
}
#[test]
fn long_answers_are_split_at_newlines() {
let short = "a".repeat(MAX_POST);
assert_eq!(split_answer(&short), [short.as_str()]);
let over = "a".repeat(MAX_POST + 1);
assert_eq!(split_answer(&over), ["a".repeat(MAX_POST), "a".to_string()]);
let lines = format!(
"{}\n{}\n{}",
"a".repeat(10_000),
"b".repeat(5_000),
"c".repeat(2_000)
);
assert_eq!(
split_answer(&lines),
[
format!("{}\n{}", "a".repeat(10_000), "b".repeat(5_000)),
"c".repeat(2_000)
]
);
let wide = "é".repeat(MAX_POST + 5);
let parts = split_answer(&wide);
assert_eq!(
parts.iter().map(|p| p.chars().count()).collect::<Vec<_>>(),
[MAX_POST, 5],
"characters, not bytes"
);
let leading = format!("\n{}", "x".repeat(MAX_POST + 1));
let parts = split_answer(&leading);
assert!(
parts
.iter()
.all(|p| !p.is_empty() && p.chars().count() <= MAX_POST),
"{:?}",
parts.iter().map(|p| p.len()).collect::<Vec<_>>()
);
assert_eq!(parts.concat(), leading, "a hard cut drops nothing");
assert_eq!(split_answer(""), [EMPTY_ANSWER]);
assert_eq!(split_answer(" \n "), [EMPTY_ANSWER]);
}
#[test]
fn a_long_answer_is_posted_in_order() {
let dir = TempDir::new("deliver-long");
let answer = format!("{}\n{}", "a".repeat(MAX_POST - 1), "b".repeat(MAX_POST));
let sent = answer.clone();
let _turns = serve_loop(&dir.path().join("loop.sock"), move |_, _| vec![done(&sent)]);
let poster = Record::default();
run(&dir, &poster, &batch(false, "hi"));
assert_eq!(
poster.texts(),
["a".repeat(MAX_POST - 1), "b".repeat(MAX_POST)]
);
}
@@ -0,0 +1,12 @@
# TEST-ONLY TLS fixtures
Generated once with `openssl` on 2026-09-23 for `gatewayd`'s TLS tests. Every key here is public
and must never be trusted anywhere but these tests. The two CA private keys were deleted after
signing; the server keys are kept because the test servers need them.
- `test-ca.pem`: the CA the tests trust (through `ca_file`).
- `server.pem`/`server.key`: `localhost` and `127.0.0.1`, signed by `test-ca`.
- `wrong-name.pem`/`wrong-name.key`: `wrong.example` only, signed by `test-ca` (a name mismatch).
- `other-ca.pem`, `other-server.pem`/`other-server.key`: a CA the tests do not trust.
Valid for 100 years from 2026-09-23.
@@ -0,0 +1,12 @@
-----BEGIN CERTIFICATE-----
MIIBszCCAVmgAwIBAgIUZK7y645vuYezo+uB8S2ktvXjLBUwCgYIKoZIzj0EAwIw
JjEkMCIGA1UEAwwbQm94bWFrZXIgVEVTVC1PTkxZIG90aGVyIENBMCAXDTI2MDky
NDAwNDgxN1oYDzIxMjYwODMxMDA0ODE3WjAmMSQwIgYDVQQDDBtCb3htYWtlciBU
RVNULU9OTFkgb3RoZXIgQ0EwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQV1sRm
ePvJps71wo1/QVUi8Y0Ra4kFhJ0WI7VLIqeINPpaQtBKHUH+SWrjR4mromEtJ8ZR
d3frK7jBFmI+AVmLo2MwYTAdBgNVHQ4EFgQUUBg/R1eSkXP7swTjnoEFSvcIGcww
HwYDVR0jBBgwFoAUUBg/R1eSkXP7swTjnoEFSvcIGcwwDwYDVR0TAQH/BAUwAwEB
/zAOBgNVHQ8BAf8EBAMCAgQwCgYIKoZIzj0EAwIDSAAwRQIhAIKFnNomDrIwpeOG
wdsm8NfXWydx7Mp2/ujRCXCqMyrAAiAo2hoprQhU3uRmyrTtokBAqE5kWFSKOa6K
BlnVrcFSvw==
-----END CERTIFICATE-----
@@ -0,0 +1,5 @@
-----BEGIN PRIVATE KEY-----
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg940CxyChSA5oYsx2
W6tmkg66INWcYxOOfUcqkvi9TBehRANCAAQBOwVQrucb42OCoWScE/Grn6DnmMBk
6yceR+ZNU9wvYwMKBovg6sErdvjACNlYIsAkjjRuO7xYQbrxJ4ixoY3O
-----END PRIVATE KEY-----
@@ -0,0 +1,12 @@
-----BEGIN CERTIFICATE-----
MIIB2zCCAYCgAwIBAgIUWxsnA8gxRlvuY1SrXg+eQxRDq7cwCgYIKoZIzj0EAwIw
JjEkMCIGA1UEAwwbQm94bWFrZXIgVEVTVC1PTkxZIG90aGVyIENBMCAXDTI2MDky
NDAwNDgxN1oYDzIxMjYwODMxMDA0ODE3WjAuMSwwKgYDVQQDDCNsb2NhbGhvc3Qg
ZnJvbSBvdGhlciBDQSAoVEVTVCBPTkxZKTBZMBMGByqGSM49AgEGCCqGSM49AwEH
A0IABAE7BVCu5xvjY4KhZJwT8aufoOeYwGTrJx5H5k1T3C9jAwoGi+DqwSt2+MAI
2VgiwCSONG47vFhBuvEniLGhjc6jgYEwfzAaBgNVHREEEzARgglsb2NhbGhvc3SH
BH8AAAEwDAYDVR0TAQH/BAIwADATBgNVHSUEDDAKBggrBgEFBQcDATAdBgNVHQ4E
FgQUdpTjXgMrZvKV54i+QnheUhd17IswHwYDVR0jBBgwFoAUUBg/R1eSkXP7swTj
noEFSvcIGcwwCgYIKoZIzj0EAwIDSQAwRgIhAJqpsTc14FSZpyWvmn6G0Ar2bxLz
CYQNanzxCPLMDGTCAiEA/F1wQjxrCikZAfuQKBKL5cc2MHf2dsSjZq2Sg5OC6z0=
-----END CERTIFICATE-----
@@ -0,0 +1,5 @@
-----BEGIN PRIVATE KEY-----
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgtj+G5HUdct3YHcZ2
rvTnI3blyTjkfEiwVwGTqOINYZChRANCAAQcwnbJi8KAjVQEQd/mIXFCSDGNcy9V
XRx5uZ+wqnAUqbmsj+LHl6q9KM1Y3bowFBIHQOjpBWvy8JA0oPRJPLWM
-----END PRIVATE KEY-----
@@ -0,0 +1,13 @@
-----BEGIN CERTIFICATE-----
MIIB4zCCAYigAwIBAgIUNQfYYHxiZvBYa5P4sYqm4yJqf9MwCgYIKoZIzj0EAwIw
PDE6MDgGA1UEAwwxQm94bWFrZXIgVEVTVC1PTkxZIENBIChuZXZlciB0cnVzdCBv
dXRzaWRlIHRlc3RzKTAgFw0yNjA5MjQwMDQ4MTdaGA8yMTI2MDgzMTAwNDgxN1ow
IDEeMBwGA1UEAwwVbG9jYWxob3N0IChURVNUIE9OTFkpMFkwEwYHKoZIzj0CAQYI
KoZIzj0DAQcDQgAEHMJ2yYvCgI1UBEHf5iFxQkgxjXMvVV0cebmfsKpwFKm5rI/i
x5eqvSjNWN26MBQSB0Do6QVr8vCQNKD0STy1jKOBgTB/MBoGA1UdEQQTMBGCCWxv
Y2FsaG9zdIcEfwAAATAMBgNVHRMBAf8EAjAAMBMGA1UdJQQMMAoGCCsGAQUFBwMB
MB0GA1UdDgQWBBRtebrcLsFuJloYqBEhrWKh4ghINTAfBgNVHSMEGDAWgBRZLO26
Eow46wSsnj/mQBJ5Hi7VZDAKBggqhkjOPQQDAgNJADBGAiEAn215O/7cosHkI5n4
7Kuq+30BXfrqBHnZ6FznHQIgIjsCIQDW8om0qRjIo5dXNIY4DLj757+KaleqaQdE
oFc079H45g==
-----END CERTIFICATE-----
@@ -0,0 +1,13 @@
-----BEGIN CERTIFICATE-----
MIIB3zCCAYWgAwIBAgIURpYQTJ2pH+M6c7wb2aMwpr96TQUwCgYIKoZIzj0EAwIw
PDE6MDgGA1UEAwwxQm94bWFrZXIgVEVTVC1PTkxZIENBIChuZXZlciB0cnVzdCBv
dXRzaWRlIHRlc3RzKTAgFw0yNjA5MjQwMDQ4MTdaGA8yMTI2MDgzMTAwNDgxN1ow
PDE6MDgGA1UEAwwxQm94bWFrZXIgVEVTVC1PTkxZIENBIChuZXZlciB0cnVzdCBv
dXRzaWRlIHRlc3RzKTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABMeSQ30pi+FZ
85pHjd7+q6bo30eJGcdwmiK2MwlASDejQb0nA4cOWqPLGdlNO4o5679DwiigSUnv
yh/V1yJ4KyqjYzBhMB0GA1UdDgQWBBRZLO26Eow46wSsnj/mQBJ5Hi7VZDAfBgNV
HSMEGDAWgBRZLO26Eow46wSsnj/mQBJ5Hi7VZDAPBgNVHRMBAf8EBTADAQH/MA4G
A1UdDwEB/wQEAwICBDAKBggqhkjOPQQDAgNIADBFAiA+LzwUA1QvGOcDNxMbnbb8
ycfuH+i16pebeH3rcJIwDAIhALgKBj1r2ItuB/Rag8Y0sYs9rx5Arlikzg2VGWoT
CYbm
-----END CERTIFICATE-----
@@ -0,0 +1,5 @@
-----BEGIN PRIVATE KEY-----
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgflra3VFKl15oCyVi
0KDJ52JphSZfIFDqAmFIUdVow0ShRANCAAQLaVnu5yblt9VdhunVTXzxk4k1ZIAv
qs0WEHCiNRfR+Wex5GpMfRCDcHH6fFlqyq5YpFV0/ripVSlt3RnH9Ok5
-----END PRIVATE KEY-----
@@ -0,0 +1,13 @@
-----BEGIN CERTIFICATE-----
MIIB4jCCAYmgAwIBAgIUNQfYYHxiZvBYa5P4sYqm4yJqf9QwCgYIKoZIzj0EAwIw
PDE6MDgGA1UEAwwxQm94bWFrZXIgVEVTVC1PTkxZIENBIChuZXZlciB0cnVzdCBv
dXRzaWRlIHRlc3RzKTAgFw0yNjA5MjQwMDQ4MTdaGA8yMTI2MDgzMTAwNDgxN1ow
JDEiMCAGA1UEAwwZd3JvbmcuZXhhbXBsZSAoVEVTVCBPTkxZKTBZMBMGByqGSM49
AgEGCCqGSM49AwEHA0IABAtpWe7nJuW31V2G6dVNfPGTiTVkgC+qzRYQcKI1F9H5
Z7Hkakx9EINwcfp8WWrKrlikVXT+uKlVKW3dGcf06TmjfzB9MBgGA1UdEQQRMA+C
DXdyb25nLmV4YW1wbGUwDAYDVR0TAQH/BAIwADATBgNVHSUEDDAKBggrBgEFBQcD
ATAdBgNVHQ4EFgQU7JXvioL6xNp1Xd8wEN1vHw32ZC4wHwYDVR0jBBgwFoAUWSzt
uhKMOOsErJ4/5kASeR4u1WQwCgYIKoZIzj0EAwIDRwAwRAIgJ+BxEK1QVQUeI/PM
Ap1A7fHECE5GTgKazmJ79DiRBa4CIEEAw9AxKBjNn5gXcQWe/zSs+cGwD6jAxdAe
hbIC76Kx
-----END CERTIFICATE-----
@@ -0,0 +1,207 @@
//! The HTTP client over an in-memory stream: what it writes, how it reads each kind of body, its
//! caps, and the wait a 429 asks for (M4a spec, section 5). Do not edit.
use std::io::{Cursor, Read, Write};
use std::time::{Duration, UNIX_EPOCH};
use gatewayd::http::{Head, HttpError, MAX_BODY, MAX_HEAD, rate_limit_wait, read_head, request};
/// Reads from `input`, records what is written.
struct Duplex {
input: Cursor<Vec<u8>>,
output: Vec<u8>,
}
impl Duplex {
fn new(input: &[u8]) -> Duplex {
Duplex {
input: Cursor::new(input.to_vec()),
output: Vec::new(),
}
}
}
impl Read for Duplex {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
self.input.read(buf)
}
}
impl Write for Duplex {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.output.write(buf)
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
fn get(response: &[u8]) -> Result<(u16, Vec<u8>), HttpError> {
let mut d = Duplex::new(response);
let r = request(&mut d, "GET", "a.example", "/api/v4/users/me", &[], None)?;
Ok((r.head.status, r.body))
}
#[test]
fn the_request_is_exactly_this() {
let mut d = Duplex::new(b"HTTP/1.1 201 Created\r\nContent-Length: 2\r\n\r\n{}");
let r = request(
&mut d,
"POST",
"straylight.example",
"/api/v4/posts",
&[
("Authorization", "Bearer t"),
("Content-Type", "application/json"),
],
Some(b"{\"message\":\"hi\"}"),
)
.unwrap();
assert_eq!(
String::from_utf8(d.output).unwrap(),
"POST /api/v4/posts HTTP/1.1\r\nHost: straylight.example\r\nAuthorization: Bearer t\r\n\
Content-Type: application/json\r\nContent-Length: 16\r\nConnection: close\r\n\r\n{\"message\":\"hi\"}"
);
assert_eq!((r.head.status, r.body), (201, b"{}".to_vec()));
}
#[test]
fn bodies_by_length_chunks_or_close() {
assert_eq!(
get(b"HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhello").unwrap(),
(200, b"hello".to_vec())
);
assert_eq!(
get(b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhello\r\n6;x=y\r\n world\r\n0\r\nTrailer: z\r\n\r\n").unwrap(),
(200, b"hello world".to_vec())
);
assert_eq!(
get(b"HTTP/1.1 200 OK\r\ntransfer-encoding: CHUNKED\r\n\r\nA\r\n0123456789\r\n0\r\n\r\n")
.unwrap()
.1,
b"0123456789".to_vec()
);
assert_eq!(
get(b"HTTP/1.0 200 OK\r\n\r\nuntil the end").unwrap(),
(200, b"until the end".to_vec())
);
assert_eq!(
get(b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\n\r\n").unwrap(),
(204, Vec::new())
);
}
#[test]
fn headers_are_found_whatever_their_case_and_trimmed() {
let mut d = Duplex::new(
b"HTTP/1.1 101 Switching Protocols\r\nUPGRADE: websocket \r\nX-A: 1\r\n\r\nFRAMES",
);
let head = read_head(&mut d).unwrap();
assert_eq!(head.status, 101);
assert_eq!(head.header("upgrade"), Some("websocket"));
assert_eq!(head.header("x-a"), Some("1"));
assert_eq!(head.header("missing"), None);
let mut rest = String::new();
d.read_to_string(&mut rest).unwrap();
assert_eq!(
rest, "FRAMES",
"read_head reads nothing past the blank line"
);
}
#[test]
fn malformed_responses_are_errors_not_panics() {
for bad in [
&b""[..],
b"HTTP/1.1 200 OK\r\nContent-Length: 5\r\n",
b"HTTP/2 200 OK\r\n\r\n",
b"HTTP/1.1 2000 OK\r\n\r\n",
b"HTTP/1.1 abc OK\r\n\r\n",
b"HTTP/1.1 99 OK\r\n\r\n",
b"HTTP/1.1 200 OK\r\nno colon here\r\n\r\n",
b"HTTP/1.1 200 OK\r\nContent-Length: five\r\n\r\n",
b"HTTP/1.1 200 OK\r\nContent-Length: 10\r\n\r\nshort",
b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\nzz\r\n",
b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhelloXX0\r\n\r\n",
b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhel",
b"HTTP/1.1 200 OK\r\n\xff\xfe: x\r\n\r\n",
] {
assert!(get(bad).is_err(), "{:?}", String::from_utf8_lossy(bad));
}
}
#[test]
fn caps_are_checked_before_reading_or_allocating() {
let mut long_head = b"HTTP/1.1 200 OK\r\nX: ".to_vec();
long_head.extend(std::iter::repeat_n(b'a', MAX_HEAD));
long_head.extend(b"\r\n\r\n");
assert!(matches!(get(&long_head), Err(HttpError::TooLarge("head"))));
let huge = format!(
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n",
MAX_BODY + 1
);
assert!(
matches!(get(huge.as_bytes()), Err(HttpError::TooLarge("body"))),
"refused from the header alone"
);
let huge_chunk = format!(
"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n{:x}\r\n",
MAX_BODY + 1
);
assert!(matches!(
get(huge_chunk.as_bytes()),
Err(HttpError::TooLarge("body"))
));
let overflow = "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\nffffffffffffffffffff\r\n";
assert!(get(overflow.as_bytes()).is_err());
let mut to_close = b"HTTP/1.0 200 OK\r\n\r\n".to_vec();
to_close.extend(std::iter::repeat_n(b'b', MAX_BODY + 1));
assert!(matches!(get(&to_close), Err(HttpError::TooLarge("body"))));
}
fn head_with(reset: Option<&str>) -> Head {
let mut headers = vec![("X-Ratelimit-Limit".to_string(), "10".to_string())];
if let Some(r) = reset {
headers.push(("X-Ratelimit-Reset".to_string(), r.to_string()));
}
Head {
status: 429,
headers,
}
}
#[test]
fn a_rate_limit_is_waited_out_within_bounds() {
let now = UNIX_EPOCH + Duration::from_secs(1_800_000_000);
assert_eq!(
rate_limit_wait(&head_with(Some("3")), now),
Duration::from_secs(3)
);
assert_eq!(
rate_limit_wait(&head_with(Some("1800000005")), now),
Duration::from_secs(5),
"a Unix time"
);
assert_eq!(
rate_limit_wait(&head_with(Some("1799999999")), now),
Duration::from_secs(1),
"already past"
);
assert_eq!(
rate_limit_wait(&head_with(Some("0")), now),
Duration::from_secs(1)
);
assert_eq!(
rate_limit_wait(&head_with(Some("999999")), now),
Duration::from_secs(60),
"capped"
);
assert_eq!(
rate_limit_wait(&head_with(None), now),
Duration::from_secs(1)
);
assert_eq!(
rate_limit_wait(&head_with(Some("soon")), now),
Duration::from_secs(1)
);
}
@@ -0,0 +1,171 @@
//! The `gatewayd` program: its usage, what stops it at start, the warning for a secret in a file,
//! and that the token never reaches its output (M4a spec, sections 3, 4 and 10). Do not edit.
#[path = "support/tmp.rs"]
mod tmp;
use std::io::{BufRead, BufReader};
use std::os::unix::fs::PermissionsExt;
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
use tmp::TempDir;
const KYLE: &str = "k0000000000000000000000000";
const TOKEN: &str = "tok-3f9a1c7e5b2d4f6a8c0e";
fn gatewayd() -> Command {
let mut c = Command::new(env!("CARGO_BIN_EXE_gatewayd"));
c.env_remove("GW_TEST_TOKEN")
.env_remove("CREDENTIALS_DIRECTORY");
c
}
fn closed_url() -> String {
let port = std::net::TcpListener::bind("127.0.0.1:0")
.unwrap()
.local_addr()
.unwrap()
.port();
format!("http://127.0.0.1:{port}")
}
fn write_config(dir: &TempDir, secret: &str) -> std::path::PathBuf {
let text = format!(
"[mattermost]\nurl = \"{}\"\n[secrets.mattermost_token]\n{secret}\n[allow]\nusers = [\"{KYLE}\"]\n[paths]\nhome = \"{}\"\n",
closed_url(),
dir.path().join("home").display()
);
dir.write("gatewayd.toml", &text)
}
/// Run until `want` appears on standard error or 5 s pass, then kill it; all it printed.
fn stderr_until(mut cmd: Command, want: &str) -> String {
let mut child = cmd
.stderr(Stdio::piped())
.stdout(Stdio::null())
.spawn()
.unwrap();
let mut reader = BufReader::new(child.stderr.take().unwrap());
let until = Instant::now() + Duration::from_secs(5);
let mut all = String::new();
while Instant::now() < until && !all.contains(want) {
let mut line = String::new();
if reader.read_line(&mut line).unwrap_or(0) == 0 {
break;
}
all.push_str(&line);
}
let _ = child.kill();
let _ = child.wait();
all
}
#[test]
fn usage() {
for args in [
&[][..],
&["serve"][..],
&["serve", "--config"][..],
&["run", "--config", "x"][..],
] {
let out = gatewayd().args(args).output().unwrap();
assert_eq!(out.status.code(), Some(2), "{args:?}");
assert_eq!(
String::from_utf8_lossy(&out.stderr),
"usage: gatewayd serve --config <path>\n"
);
}
}
#[test]
fn a_bad_config_stops_at_start() {
let dir = TempDir::new("main-config");
let missing = dir.path().join("nope.toml");
let bad = dir.write("bad.toml", "[mattermost]\nurl = \"ftp://x\"\n");
for path in [missing, bad] {
let out = gatewayd()
.arg("serve")
.arg("--config")
.arg(&path)
.output()
.unwrap();
let err = String::from_utf8_lossy(&out.stderr);
assert_eq!(out.status.code(), Some(1), "{err}");
assert!(
err.starts_with(&format!("gatewayd: {}: ", path.display())),
"{err}"
);
assert!(
err.ends_with("\nsee docs/runbook.md#gatewayd-start-failed\n"),
"{err}"
);
}
}
#[test]
fn a_missing_secret_stops_at_start() {
let dir = TempDir::new("main-secret");
let config = write_config(&dir, "env = \"GW_TEST_TOKEN\"");
let out = gatewayd()
.arg("serve")
.arg("--config")
.arg(&config)
.output()
.unwrap();
let err = String::from_utf8_lossy(&out.stderr);
assert_eq!(out.status.code(), Some(1), "{err}");
assert!(
err.starts_with("gatewayd: secret mattermost_token: "),
"{err}"
);
assert!(
err.ends_with("\nsee docs/runbook.md#secret-unavailable\n"),
"{err}"
);
assert!(
!dir.path().join("home").exists(),
"nothing is made before the secret is read"
);
}
#[test]
fn a_secret_in_a_file_warns_and_the_token_is_never_printed() {
let dir = TempDir::new("main-file");
let secret = dir.write("token", &format!("{TOKEN}\n"));
std::fs::set_permissions(&secret, std::fs::Permissions::from_mode(0o600)).unwrap();
let config = write_config(&dir, &format!("file = \"{}\"", secret.display()));
let mut cmd = gatewayd();
cmd.arg("serve").arg("--config").arg(&config);
let err = stderr_until(cmd, "trying again");
let warning = format!(
"gatewayd: warning: secret mattermost_token is read in plaintext from {}; a systemd credential keeps it encrypted at rest (see docs/runbook.md#secret-in-a-file)\n",
secret.display()
);
assert!(err.starts_with(&warning), "{err}");
assert!(
err.contains("gatewayd: cannot reach http://127.0.0.1:"),
"{err}"
);
assert!(!err.contains(TOKEN), "{err}");
let mode = std::fs::metadata(dir.path().join("home/gateway"))
.unwrap()
.permissions()
.mode()
& 0o777;
assert_eq!(mode, 0o700);
}
#[test]
fn a_secret_from_the_environment_has_no_warning() {
let dir = TempDir::new("main-env");
let config = write_config(&dir, "env = \"GW_TEST_TOKEN\"");
let mut cmd = gatewayd();
cmd.arg("serve")
.arg("--config")
.arg(&config)
.env("GW_TEST_TOKEN", TOKEN);
let err = stderr_until(cmd, "trying again");
assert!(err.starts_with("gatewayd: cannot reach "), "{err}");
assert!(!err.contains("warning") && !err.contains(TOKEN), "{err}");
}
@@ -0,0 +1,173 @@
//! Mattermost's JSON: events as the server sends them (v11.11.0 shapes), the typing request, and
//! the `posts?since` list, whose edited, deleted and root-only posts must not be answered (M4a spec,
//! sections 7 and 9). Do not edit.
use gatewayd::mm::{Event, MmError, SINCE_LIMIT, parse_event, since_list, typing};
use serde_json::json;
const KYLE: &str = "k0000000000000000000000000";
const DM: &str = "d0000000000000000000000000";
fn post(id: &str, create_at: i64) -> serde_json::Value {
json!({
"id": id, "create_at": create_at, "update_at": create_at, "edit_at": 0, "delete_at": 0,
"is_pinned": false, "user_id": KYLE, "channel_id": DM, "root_id": "", "original_id": "",
"message": "hello", "type": "", "props": {"from_bot": "true"}, "hashtags": "",
"pending_post_id": "", "reply_count": 0, "metadata": {}
})
}
fn id(n: u32) -> String {
format!("p{n:025}")
}
fn posted(post: &serde_json::Value, channel_type: &str) -> String {
json!({
"event": "posted",
"data": {
"channel_display_name": "@kyle", "channel_name": "x__y", "channel_type": channel_type,
"post": post.to_string(), "sender_name": "@kyle", "set_online": true, "team_id": ""
},
"broadcast": {"omit_users": null, "user_id": "", "channel_id": DM, "team_id": ""},
"seq": 3
})
.to_string()
}
#[test]
fn hello_replies_and_other_events() {
let hello =
json!({"event": "hello", "data": {"server_version": "11.11.0"}, "broadcast": {}, "seq": 0});
assert_eq!(parse_event(&hello.to_string()).unwrap(), Event::Hello);
assert_eq!(
parse_event(r#"{"status":"OK","seq_reply":1}"#).unwrap(),
Event::Other(String::new())
);
let typing = json!({"event": "typing", "data": {"parent_id": ""}, "broadcast": {}, "seq": 4});
assert_eq!(
parse_event(&typing.to_string()).unwrap(),
Event::Other("typing".to_string())
);
}
#[test]
fn a_posted_event_carries_the_post_as_a_string() {
let mut p = post(&id(1), 1_758_650_000_123);
p["root_id"] = json!(id(0));
p["message"] = json!("line one\nline two");
let Event::Posted { post, channel_type } = parse_event(&posted(&p, "D")).unwrap() else {
panic!("not a post")
};
assert_eq!(channel_type, "D");
assert_eq!(
(post.id.as_str(), post.root_id.as_str()),
(id(1).as_str(), id(0).as_str())
);
assert_eq!(
(post.user_id.as_str(), post.channel_id.as_str()),
(KYLE, DM)
);
assert_eq!(
(post.message.as_str(), post.create_at, post.kind.as_str()),
("line one\nline two", 1_758_650_000_123, "")
);
let mut s = self::post(&id(2), 5);
s["type"] = json!("system_join_channel");
let Event::Posted { post, .. } = parse_event(&posted(&s, "O")).unwrap() else {
panic!()
};
assert_eq!(post.kind, "system_join_channel");
}
#[test]
fn bad_posts_are_errors_not_panics() {
let p = post(&id(1), 5);
let as_object =
json!({"event": "posted", "data": {"post": p, "channel_type": "D"}}).to_string();
let mut cases = vec![
as_object,
"not json".to_string(),
"[1,2]".to_string(),
"{\"event\":5}".to_string(),
];
for (field, value) in [
("id", json!("../../etc")),
("user_id", json!("")),
("channel_id", json!("A0000000000000000000000000")),
("root_id", json!("short")),
("create_at", json!("soon")),
] {
let mut bad = post(&id(1), 5);
bad[field] = value;
cases.push(posted(&bad, "D"));
}
let mut missing = post(&id(1), 5);
missing.as_object_mut().unwrap().remove("create_at");
cases.push(posted(&missing, "D"));
for case in cases {
assert!(
matches!(parse_event(&case), Err(MmError::Json(_))),
"{case}"
);
}
}
#[test]
fn the_typing_request() {
let got: serde_json::Value = serde_json::from_str(&typing(7, DM, &id(0))).unwrap();
assert_eq!(
got,
json!({"action": "user_typing", "seq": 7, "data": {"channel_id": DM, "parent_id": id(0)}})
);
}
#[test]
fn since_keeps_new_posts_in_order_and_nothing_else() {
let since = 1000;
let mut edited = post(&id(1), 900);
edited["update_at"] = json!(1500);
let mut deleted = post(&id(2), 1100);
deleted["delete_at"] = json!(1200);
let root_only = post(&id(3), 10);
let later = post(&id(4), 1300);
let earlier = post(&id(5), 1200);
let at_since = post(&id(6), 1000);
let mut posts = serde_json::Map::new();
for p in [&edited, &deleted, &root_only, &later, &earlier, &at_since] {
posts.insert(p["id"].as_str().unwrap().to_string(), p.clone());
}
let order = json!([id(4), id(2), id(1), id(5), id(9), id(6)]);
let body = json!({"order": order, "posts": posts, "next_post_id": "", "prev_post_id": "", "has_next": false});
let got = since_list(body.to_string().as_bytes(), since).unwrap();
let ids: Vec<&str> = got.posts.iter().map(|p| p.id.as_str()).collect();
assert_eq!(ids, [id(5), id(4)]);
assert!(!got.full);
}
#[test]
fn a_full_since_answer_says_so() {
let mut posts = serde_json::Map::new();
let mut order = Vec::new();
for n in 0..SINCE_LIMIT {
let n = u32::try_from(n).unwrap();
posts.insert(id(n), post(&id(n), 2000 + i64::from(n)));
order.push(id(n));
}
let body = json!({"order": order, "posts": posts});
let got = since_list(body.to_string().as_bytes(), 1000).unwrap();
assert!(got.full);
assert_eq!(got.posts.len(), SINCE_LIMIT);
let empty = since_list(br#"{"order":[],"posts":{}}"#, 0).unwrap();
assert!(empty.posts.is_empty() && !empty.full);
}
#[test]
fn a_server_error_body_is_quoted_in_messages() {
let e = MmError::Status(500, "boom\ngatewayd: a forged line".to_string());
let text = e.to_string();
assert!(!text.contains('\n'), "{text}");
assert_eq!(
MmError::Auth(401).to_string(),
"Mattermost refused the token (401)"
);
}
@@ -0,0 +1,242 @@
//! Mattermost's REST calls against a scripted server: the requests on the wire, and what a 401,
//! 403, 429, 5xx or 404 does (M4a spec, sections 5 and 9). Do not edit.
#[path = "support/http_server.rs"]
mod http_server;
#[path = "support/tls_server.rs"]
mod tls_server;
use std::net::SocketAddr;
use std::sync::mpsc::Receiver;
use std::time::{Duration, Instant};
use gatewayd::config::ServerUrl;
use gatewayd::mm::MmError;
use gatewayd::mm::rest::Client;
use gatewayd::net::Connector;
use gatewayd::secrets::Secret;
use http_server::{Request, reply, serve_http};
use serde_json::json;
use tls_server::{fixture, server_config};
const BOT: &str = "b0000000000000000000000000";
const KYLE: &str = "k0000000000000000000000000";
const DM: &str = "d0000000000000000000000000";
const POST: &str = "p0000000000000000000000001";
fn client(addr: SocketAddr, tls: bool) -> Client {
let url = ServerUrl {
tls,
host: "localhost".to_string(),
port: addr.port(),
};
let ca = tls.then(|| fixture("test-ca.pem"));
let connector = Connector::new(url, ca.as_deref()).unwrap();
Client::new(
connector,
Secret::new("TOKEN".to_string()),
Duration::from_secs(5),
)
}
fn me_body() -> String {
json!({"id": BOT, "username": "boxmaker-straylight", "roles": "system_user", "is_bot": true})
.to_string()
}
fn requests(rx: &Receiver<Request>) -> Vec<Request> {
rx.try_iter().collect()
}
#[test]
fn me_plain_and_over_tls() {
for tls in [false, true] {
let config = tls.then(|| server_config("server"));
let (addr, rx) = serve_http(config, |_, _| reply(200, "", &me_body()));
let me = client(addr, tls).me().unwrap();
assert_eq!(
(me.id.as_str(), me.username.as_str()),
(BOT, "boxmaker-straylight")
);
let r = rx.recv_timeout(Duration::from_secs(5)).unwrap();
assert_eq!(
(r.method.as_str(), r.path.as_str()),
("GET", "/api/v4/users/me")
);
assert!(
r.head.contains("Authorization: Bearer TOKEN\r\n"),
"{}",
r.head
);
assert!(
r.head
.contains(&format!("Host: localhost:{}\r\n", addr.port())),
"{}",
r.head
);
}
}
#[test]
fn create_post_sends_the_thread_and_reads_the_post_back() {
let (addr, rx) = serve_http(None, |_, r| {
let mut p = r.json();
p["id"] = json!(POST);
p["user_id"] = json!(BOT);
p["create_at"] = json!(5);
reply(201, "", &p.to_string())
});
let post = client(addr, false)
.create_post(DM, KYLE, "an answer\nin two lines")
.unwrap();
assert_eq!((post.id.as_str(), post.root_id.as_str()), (POST, KYLE));
let r = rx.recv_timeout(Duration::from_secs(5)).unwrap();
assert_eq!(
(r.method.as_str(), r.path.as_str()),
("POST", "/api/v4/posts")
);
assert!(
r.head.contains("Content-Type: application/json\r\n"),
"{}",
r.head
);
assert_eq!(
r.json(),
json!({"channel_id": DM, "root_id": KYLE, "message": "an answer\nin two lines"})
);
}
#[test]
fn posts_since_and_the_direct_channel() {
let (addr, rx) = serve_http(None, |_, r| {
if r.path.contains("/posts?since=") {
reply(200, "", r#"{"order":[],"posts":{}}"#)
} else {
reply(201, "", &json!({"id": DM, "type": "D"}).to_string())
}
});
let c = client(addr, false);
assert!(
c.posts_since(DM, 1_758_650_000_000)
.unwrap()
.posts
.is_empty()
);
assert_eq!(c.direct_channel(BOT, KYLE).unwrap(), DM);
let rs = requests(&rx);
assert_eq!(
rs[0].path,
format!("/api/v4/channels/{DM}/posts?since=1758650000000")
);
assert_eq!(
(rs[1].method.as_str(), rs[1].path.as_str()),
("POST", "/api/v4/channels/direct")
);
assert_eq!(rs[1].json(), json!([BOT, KYLE]));
}
#[test]
fn a_channel_that_is_not_an_id_is_never_sent() {
let (addr, rx) = serve_http(None, |_, _| reply(200, "", r#"{"order":[],"posts":{}}"#));
let err = client(addr, false)
.posts_since("../users/me?x=", 0)
.unwrap_err();
assert!(matches!(err, MmError::Json(_)), "{err}");
std::thread::sleep(Duration::from_millis(100));
assert!(requests(&rx).is_empty());
}
#[test]
fn a_refused_token_stops_at_once() {
for status in [401, 403] {
let (addr, rx) = serve_http(None, move |_, _| {
reply(
status,
"",
r#"{"id":"api.context.session_expired.app_error"}"#,
)
});
let err = client(addr, false).me().unwrap_err();
assert!(matches!(err, MmError::Auth(s) if s == status), "{err}");
std::thread::sleep(Duration::from_millis(100));
assert_eq!(requests(&rx).len(), 1, "no retry on {status}");
}
}
#[test]
fn a_server_error_is_tried_twice_more() {
let (addr, rx) = serve_http(None, |n, _| {
if n < 2 {
reply(503, "", "{}")
} else {
reply(200, "", &me_body())
}
});
assert!(client(addr, false).me().is_ok());
assert_eq!(requests(&rx).len(), 3);
let (addr, rx) = serve_http(None, |_, _| reply(502, "", "{}"));
let err = client(addr, false).me().unwrap_err();
assert!(matches!(err, MmError::Status(502, _)), "{err}");
std::thread::sleep(Duration::from_millis(100));
assert_eq!(requests(&rx).len(), 3);
}
#[test]
fn a_rate_limit_is_waited_out() {
let (addr, rx) = serve_http(None, |n, _| {
if n == 0 {
reply(429, "X-Ratelimit-Reset: 1\r\n", "{}")
} else {
reply(200, "", &me_body())
}
});
let started = Instant::now();
assert!(client(addr, false).me().is_ok());
assert!(
started.elapsed() >= Duration::from_millis(900),
"{:?}",
started.elapsed()
);
assert_eq!(requests(&rx).len(), 2);
let (addr, _rx) = serve_http(None, |_, _| reply(429, "X-Ratelimit-Reset: 1\r\n", "{}"));
assert!(matches!(
client(addr, false).me(),
Err(MmError::RateLimited(_))
));
}
#[test]
fn other_statuses_keep_the_start_of_the_body() {
let long = format!("{{\"message\":\"{}\"}}", "x".repeat(1000));
let (addr, _rx) = serve_http(None, move |_, _| reply(404, "", &long));
let Err(MmError::Status(404, body)) = client(addr, false).me() else {
panic!("not a 404")
};
assert_eq!(body.chars().count(), 200);
}
#[test]
fn answers_that_are_not_what_we_expect() {
for body in [
"not json",
r#"{"id":"short","username":"x"}"#,
r#"{"id":"b0000000000000000000000000","username":""}"#,
] {
let (addr, _rx) = serve_http(None, move |_, _| reply(200, "", body));
assert!(
matches!(client(addr, false).me(), Err(MmError::Json(_))),
"{body}"
);
}
}
#[test]
fn nobody_listening_is_a_network_error() {
let port = std::net::TcpListener::bind("127.0.0.1:0")
.unwrap()
.local_addr()
.unwrap();
assert!(matches!(client(port, false).me(), Err(MmError::Net(_))));
}
@@ -0,0 +1,120 @@
//! Connections, plain and TLS, against local servers with TEST-ONLY certificates (M4a spec,
//! section 5): the right CA is trusted through `ca_file`; an unknown CA and a wrong name are
//! refused, so verification is on. Do not edit.
#[path = "support/tls_server.rs"]
mod tls_server;
use std::io::{BufRead, BufReader, Write};
use std::time::Duration;
use gatewayd::config::ServerUrl;
use gatewayd::net::{Connector, NetError, Stream};
use tls_server::{echo_line, fixture, serve, server_config};
const T: Duration = Duration::from_secs(5);
fn url(tls: bool, host: &str, port: u16) -> ServerUrl {
ServerUrl {
tls,
host: host.to_string(),
port,
}
}
fn round_trip(mut stream: Stream) -> String {
stream.write_all(b"hello over the wire\n").unwrap();
stream.flush().unwrap();
let mut line = String::new();
BufReader::new(stream).read_line(&mut line).unwrap();
line
}
#[test]
fn plain_tcp() {
let addr = serve(None, echo_line);
let c = Connector::new(url(false, "127.0.0.1", addr.port()), None).unwrap();
let stream = c.connect(T).unwrap();
assert!(matches!(stream, Stream::Plain(_)));
assert_eq!(round_trip(stream), "hello over the wire\n");
}
#[test]
fn tls_to_a_server_signed_by_the_ca_file() {
let addr = serve(Some(server_config("server")), echo_line);
for host in ["localhost", "127.0.0.1"] {
let c =
Connector::new(url(true, host, addr.port()), Some(&fixture("test-ca.pem"))).unwrap();
let stream = c.connect(T).unwrap_or_else(|e| panic!("{host}: {e}"));
assert!(matches!(stream, Stream::Tls(_)));
assert_eq!(round_trip(stream), "hello over the wire\n", "{host}");
}
}
#[test]
fn an_unknown_ca_is_refused_at_connect() {
let addr = serve(Some(server_config("other-server")), echo_line);
let c = Connector::new(
url(true, "localhost", addr.port()),
Some(&fixture("test-ca.pem")),
)
.unwrap();
match c.connect(T) {
Err(NetError::Tls(why)) => assert!(why.to_lowercase().contains("certificate"), "{why}"),
Err(e) => panic!("expected a TLS error, got {e}"),
Ok(_) => panic!("a server signed by an unknown CA was accepted"),
}
}
#[test]
fn a_wrong_name_is_refused_at_connect() {
let addr = serve(Some(server_config("wrong-name")), echo_line);
let c = Connector::new(
url(true, "localhost", addr.port()),
Some(&fixture("test-ca.pem")),
)
.unwrap();
assert!(
matches!(c.connect(T), Err(NetError::Tls(_))),
"a certificate for wrong.example was accepted for localhost"
);
}
#[test]
fn tls_to_a_plain_server_fails_and_does_not_hang() {
let addr = serve(None, |_conn| std::thread::sleep(Duration::from_secs(30)));
let c = Connector::new(
url(true, "localhost", addr.port()),
Some(&fixture("test-ca.pem")),
)
.unwrap();
let started = std::time::Instant::now();
assert!(c.connect(Duration::from_millis(300)).is_err());
assert!(started.elapsed() < Duration::from_secs(3));
}
#[test]
fn a_bad_ca_file_is_an_error_before_any_connection() {
let missing = fixture("no-such.pem");
assert!(matches!(
Connector::new(url(true, "localhost", 1), Some(&missing)),
Err(NetError::Roots(_))
));
let not_pem = fixture("README.md");
assert!(matches!(
Connector::new(url(true, "localhost", 1), Some(&not_pem)),
Err(NetError::Roots(_))
));
// A plain server needs no certificates at all.
assert!(Connector::new(url(false, "localhost", 1), Some(&missing)).is_ok());
}
#[test]
fn nothing_listening_is_a_connect_error() {
let port = {
let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
l.local_addr().unwrap().port()
};
let c = Connector::new(url(false, "127.0.0.1", port), None).unwrap();
assert!(matches!(c.connect(T), Err(NetError::Connect(_))));
}
@@ -0,0 +1,200 @@
//! The secret store (M4a spec, section 4). The environment is passed in as a function, so no test
//! changes the process's environment. Do not edit.
#[path = "support/tmp.rs"]
mod tmp;
use std::collections::HashMap;
use std::ffi::OsString;
use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf;
use gatewayd::config::SecretSource;
use gatewayd::secrets::{RUNBOOK, RUNBOOK_FILE, load};
use tmp::TempDir;
const TOKEN: &str = "s3cret-t0ken-value";
fn env_of(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<OsString> + use<> {
let map: HashMap<String, OsString> = pairs
.iter()
.map(|(k, v)| (k.to_string(), OsString::from(v)))
.collect();
move |k| map.get(k).cloned()
}
fn owner_file(dir: &TempDir, name: &str, text: &str, mode: u32) -> PathBuf {
let path = dir.write(name, text);
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(mode)).unwrap();
path
}
fn refused(source: &SecretSource, env: &dyn Fn(&str) -> Option<OsString>, word: &str) {
let e = load("mattermost_token", source, env).expect_err(word);
let text = e.to_string();
assert!(text.contains(word), "{word}: {text}");
assert!(text.starts_with("secret mattermost_token: "), "{text}");
assert!(text.ends_with(RUNBOOK), "{text}");
assert!(
!text.contains(TOKEN),
"a refusal never shows the value: {text}"
);
}
#[test]
fn a_systemd_credential() {
let dir = TempDir::new("cred");
dir.write("creds/mattermost-token", &format!("{TOKEN}\n"));
let creds = dir.path().join("creds");
let env = env_of(&[("CREDENTIALS_DIRECTORY", creds.to_str().unwrap())]);
let got = load(
"mattermost_token",
&SecretSource::Credential("mattermost-token".into()),
&env,
)
.unwrap();
assert_eq!(
got.secret.expose(),
TOKEN,
"one trailing newline is removed"
);
assert_eq!(got.warning, None);
}
#[test]
fn a_credential_outside_systemd_or_missing_is_refused() {
let src = SecretSource::Credential("mattermost-token".into());
refused(&src, &env_of(&[]), "CREDENTIALS_DIRECTORY is not set");
let dir = TempDir::new("cred-missing");
refused(
&src,
&env_of(&[("CREDENTIALS_DIRECTORY", dir.path().to_str().unwrap())]),
"cannot read the credential",
);
}
#[test]
fn an_environment_variable() {
let got = load(
"mattermost_token",
&SecretSource::Env("MM".into()),
&env_of(&[("MM", TOKEN)]),
)
.unwrap();
assert_eq!(got.secret.expose(), TOKEN);
assert_eq!(got.warning, None, "only a file warns");
refused(&SecretSource::Env("MM".into()), &env_of(&[]), "is not set");
refused(
&SecretSource::Env("MM".into()),
&env_of(&[("MM", "")]),
"empty",
);
}
#[test]
fn an_owner_only_file_is_read_with_a_warning() {
let dir = TempDir::new("file");
for mode in [0o600, 0o400] {
let path = owner_file(
&dir,
&format!("token-{mode:o}"),
&format!("{TOKEN}\n"),
mode,
);
let got = load(
"mattermost_token",
&SecretSource::File(path.clone()),
&env_of(&[]),
)
.unwrap();
assert_eq!(got.secret.expose(), TOKEN);
let warning = got.warning.expect("a file secret warns");
assert!(
warning.starts_with(
"gatewayd: warning: secret mattermost_token is read in plaintext from "
),
"{warning}"
);
assert!(warning.contains(path.to_str().unwrap()), "{warning}");
assert!(warning.contains(RUNBOOK_FILE), "{warning}");
assert!(!warning.contains(TOKEN));
}
}
#[test]
fn a_file_anyone_else_can_read_or_that_is_not_a_plain_file_is_refused() {
let dir = TempDir::new("file-bad");
for (mode, _) in [
(0o640, "group"),
(0o604, "other"),
(0o644, "both"),
(0o660, "group write"),
] {
let path = owner_file(&dir, &format!("t-{mode:o}"), TOKEN, mode);
refused(
&SecretSource::File(path),
&env_of(&[]),
"only the owner may read it",
);
}
let target = owner_file(&dir, "real", TOKEN, 0o600);
let link = dir.path().join("link");
std::os::unix::fs::symlink(&target, &link).unwrap();
refused(&SecretSource::File(link), &env_of(&[]), "symbolic link");
std::fs::create_dir(dir.path().join("adir")).unwrap();
refused(
&SecretSource::File(dir.path().join("adir")),
&env_of(&[]),
"not a regular file",
);
refused(
&SecretSource::File(dir.path().join("missing")),
&env_of(&[]),
"cannot read",
);
refused(
&SecretSource::File(PathBuf::from("relative/token")),
&env_of(&[]),
"absolute",
);
}
#[test]
fn empty_and_non_utf8_values_are_refused() {
let dir = TempDir::new("value");
refused(
&SecretSource::File(owner_file(&dir, "empty", "", 0o600)),
&env_of(&[]),
"empty",
);
refused(
&SecretSource::File(owner_file(&dir, "nl", "\n", 0o600)),
&env_of(&[]),
"empty",
);
let path = dir.path().join("bin");
std::fs::write(&path, [0xff, 0xfe]).unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
refused(&SecretSource::File(path), &env_of(&[]), "not UTF-8");
}
#[test]
fn only_one_trailing_newline_is_removed_and_spaces_stay() {
let dir = TempDir::new("trim");
let path = owner_file(&dir, "t", " a b \n\n", 0o600);
let got = load("x", &SecretSource::File(path), &env_of(&[])).unwrap();
assert_eq!(got.secret.expose(), " a b \n");
}
#[test]
fn a_secret_prints_nothing_of_itself() {
let got = load(
"mattermost_token",
&SecretSource::Env("MM".into()),
&env_of(&[("MM", TOKEN)]),
)
.unwrap();
let shown = format!("{:?} {:?}", got.secret, got);
assert!(!shown.contains(TOKEN), "{shown}");
assert!(shown.contains("Secret(…)"), "{shown}");
}
@@ -0,0 +1,213 @@
//! `gatewayd` end to end, against a fake Mattermost and a fake `loopd`: who is answered, where,
//! and how (M4a spec, sections 7 and 8). Do not edit.
#[path = "support/fake_loop.rs"]
mod fake_loop;
#[path = "support/fake_mm.rs"]
mod fake_mm;
#[path = "support/gateway.rs"]
mod gateway;
#[path = "support/tmp.rs"]
mod tmp;
use std::sync::{Mutex, mpsc};
use std::time::Duration;
use fake_loop::{done, serve_loop};
use fake_mm::{BOT, BOT_NAME, DM, EVE, EVE_DM, FakeMm, KYLE, id, post};
use gateway::{OTHER, SHARED, WAIT, config, loop_dir, read_state, start, up};
use gatewayd::serve::Stop;
use gatewayd::sessions::{BUSY, M4B_COMMAND};
use tmp::TempDir;
#[test]
fn a_direct_message_is_answered_in_its_thread_while_typing() {
let home = TempDir::new("serve-dm");
let (mm, running, turns, mut ws) = up(&home, Duration::from_millis(600));
let p1 = id('p', 1);
ws.posted(&post(&p1, KYLE, DM, "", "hello there", 5), "D");
let turn = turns.recv_timeout(WAIT).unwrap();
assert_eq!(
(turn.session.as_str(), turn.content.as_str(), turn.resume),
(format!("mm-{p1}").as_str(), "hello there", false)
);
let typing = ws.typing_within(Duration::from_millis(500));
assert!(typing.len() >= 2, "{typing:?}");
assert!(
typing.iter().all(|(c, p)| c == DM && *p == p1),
"{typing:?}"
);
let posts = mm.wait_posts(1, WAIT);
assert_eq!(
posts,
[(
DM.to_string(),
p1.clone(),
"answer to hello there".to_string()
)]
);
// Typing sent just before the answer may still be on its way; after that, it stops.
ws.typing_within(Duration::from_millis(300));
assert!(
ws.typing_within(Duration::from_millis(400)).is_empty(),
"typing stops after the answer"
);
let log = running.log();
assert!(
log.iter()
.any(|l| l == &format!("gatewayd: connected to {} as {BOT_NAME}", mm.url())),
"{log:?}"
);
assert!(matches!(running.finish(), Stop::Asked));
}
#[test]
fn anyone_else_gets_nothing_at_all() {
let home = TempDir::new("serve-stranger");
let (mm, running, turns, mut ws) = up(&home, Duration::ZERO);
let p1 = id('p', 1);
ws.posted(&post(&p1, EVE, EVE_DM, "", "secret words", 5), "D");
let mut own = post(&id('p', 2), BOT, DM, "", "my own post", 6);
own["user_id"] = serde_json::json!(BOT);
ws.posted(&own, "D");
let log = running.wait_log("not allowed");
assert!(turns.recv_timeout(Duration::from_millis(300)).is_err());
assert!(ws.typing_within(Duration::from_millis(200)).is_empty());
assert!(mm.posts().is_empty());
assert!(
log.contains(&format!(
"gatewayd: ignored post {p1} from {EVE}: not allowed"
)),
"{log:?}"
);
assert!(!log.iter().any(|l| l.contains("secret words")), "{log:?}");
}
#[test]
fn messages_during_a_turn_go_together_in_the_next() {
let home = TempDir::new("serve-burst");
loop_dir(&home);
let (release_tx, release_rx) = mpsc::channel::<()>();
let release = Mutex::new(release_rx);
let turns = serve_loop(&home.path().join("run/loop/loop.sock"), move |n, turn| {
if n == 0 {
let _ = release.lock().unwrap().recv_timeout(WAIT);
}
vec![done(&format!("answer to {}", turn.content))]
});
let mm = FakeMm::start();
let _running = start(config(&home, &mm.url(), ""));
let mut ws = mm.next_ws(WAIT);
let p1 = id('p', 1);
ws.posted(&post(&p1, KYLE, DM, "", "one", 5), "D");
assert_eq!(turns.recv_timeout(WAIT).unwrap().content, "one");
let saved = read_state(&home);
assert_eq!(
saved["in_flight"],
serde_json::json!([{"session": format!("mm-{p1}"), "channel": DM, "root": p1}])
);
ws.posted(&post(&id('p', 2), KYLE, DM, &p1, "two", 6), "D");
ws.posted(&post(&id('p', 3), KYLE, DM, &p1, "three", 7), "D");
std::thread::sleep(Duration::from_millis(200));
release_tx.send(()).unwrap();
let second = turns.recv_timeout(WAIT).unwrap();
assert_eq!(
(
second.session.as_str(),
second.content.as_str(),
second.resume
),
(format!("mm-{p1}").as_str(), "two\n\nthree", true)
);
let posts = mm.wait_posts(2, WAIT);
let texts: Vec<&str> = posts.iter().map(|(_, _, t)| t.as_str()).collect();
assert_eq!(texts, ["answer to one", "answer to two\n\nthree"]);
}
#[test]
fn a_full_queue_says_busy() {
let home = TempDir::new("serve-busy");
loop_dir(&home);
let (release_tx, release_rx) = mpsc::channel::<()>();
let release = Mutex::new(release_rx);
let _turns = serve_loop(&home.path().join("run/loop/loop.sock"), move |_, _| {
let _ = release.lock().unwrap().recv_timeout(WAIT);
vec![done("ok")]
});
let mm = FakeMm::start();
let _running = start(config(&home, &mm.url(), "queue = 1"));
let mut ws = mm.next_ws(WAIT);
let p1 = id('p', 1);
for (n, text) in ["run", "waits", "too many"].iter().enumerate() {
let n = u32::try_from(n).unwrap();
let root = if n == 0 { String::new() } else { p1.clone() };
ws.posted(
&post(&id('p', n + 1), KYLE, DM, &root, text, i64::from(n) + 5),
"D",
);
}
let posts = mm.wait_posts(1, WAIT);
assert_eq!(posts, [(DM.to_string(), p1, BUSY.to_string())]);
release_tx.send(()).unwrap();
release_tx.send(()).unwrap();
}
#[test]
fn channels_are_answered_only_when_named_or_in_our_thread() {
let home = TempDir::new("serve-channel");
let (mm, _running, turns, mut ws) = up(&home, Duration::ZERO);
let p1 = id('p', 1);
ws.posted(
&post(&id('p', 9), KYLE, SHARED, "", "hello everyone", 4),
"O",
);
ws.posted(
&post(
&id('p', 8),
KYLE,
OTHER,
"",
"@boxmaker-straylight elsewhere",
4,
),
"O",
);
ws.posted(
&post(&p1, KYLE, SHARED, "", "@boxmaker-straylight start", 5),
"O",
);
assert_eq!(
turns.recv_timeout(WAIT).unwrap().content,
"@boxmaker-straylight start"
);
mm.wait_posts(1, WAIT);
ws.posted(
&post(&id('p', 2), KYLE, SHARED, &p1, "@hermes your turn", 6),
"O",
);
ws.posted(&post(&id('p', 3), KYLE, SHARED, &p1, "and more", 7), "O");
let next = turns.recv_timeout(WAIT).unwrap();
assert_eq!((next.content.as_str(), next.resume), ("and more", true));
let posts = mm.wait_posts(2, WAIT);
assert!(
posts.iter().all(|(c, r, _)| c == SHARED && *r == p1),
"{posts:?}"
);
assert!(turns.recv_timeout(Duration::from_millis(200)).is_err());
let saved = read_state(&home);
assert!(saved["channels"].get(OTHER).is_none(), "{saved}");
assert_eq!(saved["threads"], serde_json::json!([p1]));
}
#[test]
fn commands_are_answered_without_a_turn() {
let home = TempDir::new("serve-command");
let (mm, _running, turns, mut ws) = up(&home, Duration::ZERO);
let p1 = id('p', 1);
ws.posted(&post(&p1, KYLE, DM, "", "!approve 3", 5), "D");
assert_eq!(
mm.wait_posts(1, WAIT),
[(DM.to_string(), p1, M4B_COMMAND.to_string())]
);
assert!(turns.recv_timeout(Duration::from_millis(200)).is_err());
}
@@ -0,0 +1,192 @@
//! `gatewayd` end to end across gaps: a restart, a lost connection, an unreachable server, a
//! refused token and a damaged state file (M4a spec, section 9). Do not edit.
#[path = "support/fake_loop.rs"]
mod fake_loop;
#[path = "support/fake_mm.rs"]
mod fake_mm;
#[path = "support/gateway.rs"]
mod gateway;
#[path = "support/tmp.rs"]
mod tmp;
use std::sync::{Mutex, mpsc};
use std::time::Duration;
use fake_loop::{done, serve_loop};
use fake_mm::{DM, FakeMm, KYLE, id, post};
use gateway::{SHARED, WAIT, answering, config, loop_dir, start, up};
use gatewayd::serve::{INTERRUPTED, Stop};
use tmp::TempDir;
#[test]
fn a_restart_reports_the_cut_off_turn_and_catches_up() {
let home = TempDir::new("serve-restart");
let (cut, seen, new1, new2) = (id('r', 1), id('p', 2), id('p', 3), id('p', 4));
let state = serde_json::json!({
"channels": {DM: 1000}, "recent": [seen], "threads": [],
"in_flight": [{"session": format!("mm-{cut}"), "channel": DM, "root": cut}]
});
home.write("gateway/state.json", &state.to_string());
loop_dir(&home);
let turns = answering(&home, Duration::ZERO);
let mm = FakeMm::start();
mm.set_since(
DM,
&[
post(&new2, KYLE, DM, "", "second", 2000),
post(&seen, KYLE, DM, "", "already answered", 1500),
post(&new1, KYLE, DM, "", "first", 1800),
],
);
let _running = start(config(&home, &mm.url(), ""));
let _ws = mm.next_ws(WAIT);
let first = turns.recv_timeout(WAIT).unwrap();
let second = turns.recv_timeout(WAIT).unwrap();
assert_eq!(
(first.content.as_str(), second.content.as_str()),
("first", "second")
);
let posts = mm.wait_posts(3, WAIT);
assert_eq!(posts[0], (DM.to_string(), cut, INTERRUPTED.to_string()));
assert_eq!(posts.len(), 3, "{posts:?}");
std::thread::sleep(Duration::from_millis(100));
let saved: serde_json::Value = serde_json::from_str(
&std::fs::read_to_string(home.path().join("gateway/state.json")).unwrap(),
)
.unwrap();
assert_eq!(saved["in_flight"], serde_json::json!([]));
assert_eq!(saved["channels"][DM], 2000);
}
#[test]
fn a_first_start_answers_no_history() {
let home = TempDir::new("serve-first");
loop_dir(&home);
let turns = answering(&home, Duration::ZERO);
let mm = FakeMm::start();
mm.set_since(DM, &[post(&id('p', 1), KYLE, DM, "", "old", 5)]);
let running = start(config(&home, &mm.url(), ""));
let _ws = mm.next_ws(WAIT);
running.wait_log("connected to");
assert!(turns.recv_timeout(Duration::from_millis(300)).is_err());
assert!(
!mm.calls().iter().any(|(_, p)| p.contains("since=")),
"{:?}",
mm.calls()
);
let saved: serde_json::Value = serde_json::from_str(
&std::fs::read_to_string(home.path().join("gateway/state.json")).unwrap(),
)
.unwrap();
assert!(
saved["channels"][DM].as_i64().unwrap() > 1_700_000_000_000,
"marked from now"
);
assert!(saved["channels"][SHARED].as_i64().is_some());
}
#[test]
fn a_lost_connection_is_made_again() {
let home = TempDir::new("serve-reconnect");
let (mm, running, turns, ws) = up(&home, Duration::ZERO);
ws.drop_connection();
let mut again = mm.next_ws(WAIT);
let log = running.wait_log("lost the connection");
assert!(
log.iter()
.any(|l| l.ends_with("see docs/runbook.md#mattermost-unreachable")),
"{log:?}"
);
let p1 = id('p', 1);
again.posted(&post(&p1, KYLE, DM, "", "still there?", 5), "D");
assert_eq!(turns.recv_timeout(WAIT).unwrap().content, "still there?");
assert_eq!(mm.wait_posts(1, WAIT).len(), 1);
}
#[test]
fn a_refused_token_stops_gatewayd() {
let home = TempDir::new("serve-auth");
let mm = FakeMm::start();
mm.refuse_token();
let running = start(config(&home, &mm.url(), ""));
let stop = running.join_within();
assert!(matches!(stop, Stop::Auth(401)), "{stop}");
assert_eq!(
stop.to_string(),
"gatewayd: Mattermost refused the token (401)\nsee docs/runbook.md#mattermost-auth-failed"
);
}
#[test]
fn an_unreachable_server_is_tried_again() {
let home = TempDir::new("serve-unreachable");
let port = std::net::TcpListener::bind("127.0.0.1:0")
.unwrap()
.local_addr()
.unwrap()
.port();
let url = format!("http://127.0.0.1:{port}");
let running = start(config(&home, &url, ""));
std::thread::sleep(Duration::from_millis(300));
let log = running.log();
let tries: Vec<&String> = log
.iter()
.filter(|l| l.starts_with(&format!("gatewayd: cannot reach {url}: ")))
.collect();
assert!(tries.len() >= 2, "{log:?}");
assert!(
tries
.iter()
.all(|l| l
.ends_with("; trying again in 0 s\nsee docs/runbook.md#mattermost-unreachable")),
"{tries:?}"
);
assert!(matches!(running.finish(), Stop::Asked));
}
#[test]
fn a_damaged_state_file_stops_at_once() {
let home = TempDir::new("serve-damaged");
home.write("gateway/state.json", "{not json");
let mm = FakeMm::start();
let running = start(config(&home, &mm.url(), ""));
let stop = running.join_within();
assert!(matches!(stop, Stop::State(_)), "{stop}");
assert!(
stop.to_string()
.ends_with("see docs/runbook.md#gateway-state-damaged"),
"{stop}"
);
assert!(mm.calls().is_empty(), "nothing is asked of Mattermost");
}
#[test]
fn a_reconnect_does_not_interrupt_a_running_turn() {
let home = TempDir::new("serve-reconnect-turn");
loop_dir(&home);
let (release_tx, release_rx) = mpsc::channel::<()>();
let release = Mutex::new(release_rx);
let turns = serve_loop(&home.path().join("run/loop/loop.sock"), move |_, _| {
let _ = release.lock().unwrap().recv_timeout(WAIT);
vec![done("the answer")]
});
let mm = FakeMm::start();
let running = start(config(&home, &mm.url(), ""));
let mut ws = mm.next_ws(WAIT);
let p1 = id('p', 1);
ws.posted(&post(&p1, KYLE, DM, "", "a long one", 5), "D");
turns.recv_timeout(WAIT).unwrap();
ws.drop_connection();
let _again = mm.next_ws(WAIT);
running.wait_log("lost the connection");
std::thread::sleep(Duration::from_millis(100));
release_tx.send(()).unwrap();
let posts = mm.wait_posts(1, WAIT);
std::thread::sleep(Duration::from_millis(200));
assert_eq!(
mm.posts(),
[(DM.to_string(), p1, "the answer".to_string())],
"{posts:?}"
);
}
@@ -0,0 +1,298 @@
//! Which posts become turns, in which session, and how messages wait for a running turn (M4a spec,
//! section 7, including its table of examples). Do not edit.
use gatewayd::mm::Post;
use gatewayd::sessions::{
BUSY, Batch, Ignored, M4B_COMMAND, Message, Pushed, Queues, Route, Router, Thread,
UNKNOWN_COMMAND, named,
};
use proto::SessionId;
const BOT: &str = "b0000000000000000000000000";
const KYLE: &str = "k0000000000000000000000000";
const EVE: &str = "e0000000000000000000000000";
const DM: &str = "d0000000000000000000000000";
const SHARED: &str = "c0000000000000000000000000";
const OTHER: &str = "o0000000000000000000000000";
const ROOT: &str = "r0000000000000000000000000";
const POST: &str = "p0000000000000000000000000";
fn router() -> Router {
Router::new(
BOT,
"boxmaker-straylight",
&[KYLE.to_string()],
&[SHARED.to_string()],
)
}
fn post(channel: &str, root: &str, message: &str) -> Post {
Post {
id: POST.to_string(),
user_id: KYLE.to_string(),
channel_id: channel.to_string(),
root_id: root.to_string(),
message: message.to_string(),
create_at: 5,
delete_at: 0,
kind: String::new(),
}
}
fn known(root: &str) -> bool {
root == ROOT
}
fn unknown(_: &str) -> bool {
false
}
fn queued(route: Route) -> Message {
match route {
Route::Queue(m) => m,
other => panic!("not queued: {other:?}"),
}
}
#[test]
fn the_examples_in_the_spec() {
let r = router();
let cases = [
("", "@boxmaker-straylight summarise the audit log", true),
(ROOT, "and the older files?", true),
(ROOT, "@hermes what do you think?", false),
("", "@boxmaker-straylight @hermes compare notes", true),
("", "@boxmaker-straylightx hello", false),
("", "@channel standup in five", false),
];
for (root, message, yes) in cases {
let got = r.route(&post(SHARED, root, message), "O", &known);
assert_eq!(matches!(got, Route::Queue(_)), yes, "{message}: {got:?}");
if !yes {
assert_eq!(got, Route::Ignore(Ignored::NotForUs), "{message}");
}
}
}
#[test]
fn naming() {
assert_eq!(named("@Boxmaker-Straylight, look"), ["boxmaker-straylight"]);
assert_eq!(named("ask @boxmaker-straylight."), ["boxmaker-straylight"]);
assert_eq!(named("@a.b_c-d... and @e"), ["a.b_c-d", "e"]);
assert_eq!(named("@ alone, @@x, trailing @"), ["x"]);
assert_eq!(named("ünïcødé @ʙob @bob"), ["bob"]);
assert!(named("no names here").is_empty());
let r = router();
for message in [
"hi @BOXMAKER-STRAYLIGHT",
"@boxmaker-straylight.",
"(@boxmaker-straylight)",
] {
assert!(
matches!(
r.route(&post(SHARED, "", message), "P", &unknown),
Route::Queue(_)
),
"{message}"
);
}
}
#[test]
fn replies_in_our_thread_that_name_everyone_are_still_ours() {
let r = router();
for message in ["@here any news?", "@all done", "thanks @channel"] {
assert!(
matches!(
r.route(&post(SHARED, ROOT, message), "O", &known),
Route::Queue(_)
),
"{message}"
);
}
let got = r.route(
&post(
SHARED,
"q0000000000000000000000000",
"a reply in someone else's thread",
),
"O",
&known,
);
assert_eq!(got, Route::Ignore(Ignored::NotForUs));
}
#[test]
fn who_and_where() {
let r = router();
let mut own = post(DM, "", "hello");
own.user_id = BOT.to_string();
assert_eq!(r.route(&own, "D", &unknown), Route::Ignore(Ignored::Own));
let mut system = post(DM, "", "joined");
system.kind = "system_join_channel".to_string();
assert_eq!(
r.route(&system, "D", &unknown),
Route::Ignore(Ignored::System)
);
let mut stranger = post(DM, "", "@boxmaker-straylight hello");
stranger.user_id = EVE.to_string();
assert_eq!(
r.route(&stranger, "D", &unknown),
Route::Ignore(Ignored::NotAllowed)
);
let mut stranger_cmd = post(DM, "", "!approve 1");
stranger_cmd.user_id = EVE.to_string();
assert_eq!(
r.route(&stranger_cmd, "D", &unknown),
Route::Ignore(Ignored::NotAllowed)
);
let naming = "@boxmaker-straylight hello";
assert_eq!(
r.route(&post(OTHER, "", naming), "O", &unknown),
Route::Ignore(Ignored::NotForUs)
);
assert_eq!(
r.route(&post(SHARED, "", naming), "X", &unknown),
Route::Ignore(Ignored::NotForUs)
);
assert!(matches!(
r.route(&post(SHARED, "", naming), "G", &unknown),
Route::Queue(_)
));
assert!(matches!(
r.route(&post(DM, "", "no name needed"), "D", &unknown),
Route::Queue(_)
));
}
#[test]
fn sessions_and_threads() {
let r = router();
let top = queued(r.route(&post(DM, "", "hello"), "D", &unknown));
assert_eq!(top.session.as_str(), format!("mm-{POST}"));
assert_eq!(
top.thread,
Thread {
channel: DM.to_string(),
root: POST.to_string()
}
);
assert!(!top.resume && !top.joins_thread);
assert_eq!(top.text, "hello");
let reply = queued(r.route(&post(DM, ROOT, "more"), "D", &unknown));
assert_eq!(reply.session.as_str(), format!("mm-{ROOT}"));
assert_eq!(reply.thread.root, ROOT);
assert!(reply.resume);
let channel = queued(r.route(&post(SHARED, "", "@boxmaker-straylight hi"), "O", &unknown));
assert!(channel.joins_thread && !channel.resume);
assert_eq!(channel.text, "@boxmaker-straylight hi");
}
#[test]
fn commands() {
let r = router();
let thread = Thread {
channel: DM.to_string(),
root: ROOT.to_string(),
};
let reply = |text: &str| Route::Reply {
thread: thread.clone(),
text: text.to_string(),
};
for (message, answer) in [
("!approve 42", M4B_COMMAND),
("!deny 42 not now", M4B_COMMAND),
("!deny", M4B_COMMAND),
("!approved", UNKNOWN_COMMAND),
("!help", UNKNOWN_COMMAND),
("!", UNKNOWN_COMMAND),
] {
assert_eq!(
r.route(&post(DM, ROOT, message), "D", &unknown),
reply(answer),
"{message}"
);
}
assert_eq!(
queued(r.route(&post(DM, ROOT, "!!approve is a word"), "D", &unknown)).text,
"!approve is a word"
);
assert_eq!(
queued(r.route(&post(DM, ROOT, "!!"), "D", &unknown)).text,
"!"
);
assert_eq!(
queued(r.route(&post(DM, ROOT, " !help"), "D", &unknown)).text,
" !help"
);
}
fn message(root: &str, resume: bool, text: &str) -> Message {
Message {
session: SessionId::new(&format!("mm-{root}")).unwrap(),
thread: Thread {
channel: DM.to_string(),
root: root.to_string(),
},
resume,
text: text.to_string(),
joins_thread: false,
}
}
#[test]
fn messages_wait_for_the_running_turn_and_go_together() {
let mut q = Queues::new(3);
let first = message(ROOT, false, "one");
let Pushed::Start(batch) = q.push(first.clone()) else {
panic!("not started")
};
assert_eq!(
batch,
Batch {
session: first.session.clone(),
thread: first.thread.clone(),
resume: false,
text: "one".to_string()
}
);
assert_eq!(q.push(message(ROOT, true, "two")), Pushed::Waiting);
assert_eq!(q.push(message(ROOT, true, "three\nlines")), Pushed::Waiting);
let other = message(POST, false, "elsewhere");
assert!(
matches!(q.push(other.clone()), Pushed::Start(_)),
"another session starts at once"
);
assert_eq!(q.running(), 2);
let mut roots: Vec<String> = q.threads().into_iter().map(|t| t.root).collect();
roots.sort();
assert_eq!(roots, [POST, ROOT]);
let next = q.finish(&first.session).unwrap();
assert_eq!(
(next.text.as_str(), next.resume),
("two\n\nthree\nlines", true)
);
assert_eq!(q.finish(&first.session), None);
assert_eq!(q.finish(&other.session), None);
assert_eq!(q.running(), 0);
assert!(
matches!(q.push(message(ROOT, true, "later")), Pushed::Start(_)),
"idle again"
);
}
#[test]
fn a_full_queue_drops_the_message() {
let mut q = Queues::new(2);
let m = message(ROOT, false, "run");
assert!(matches!(q.push(m.clone()), Pushed::Start(_)));
assert_eq!(q.push(message(ROOT, true, "a")), Pushed::Waiting);
assert_eq!(q.push(message(ROOT, true, "b")), Pushed::Waiting);
assert_eq!(
q.push(message(ROOT, true, "c")),
Pushed::Full(m.thread.clone())
);
assert_eq!(q.finish(&m.session).unwrap().text, "a\n\nb");
assert!(!BUSY.is_empty());
assert_eq!(q.finish(&SessionId::new("mm-never").unwrap()), None);
}
@@ -0,0 +1,178 @@
//! The state file: a first start, surviving a restart, its limits, and refusing a damaged file
//! instead of guessing (M4a spec, section 9). Do not edit.
#[path = "support/tmp.rs"]
mod tmp;
use std::os::unix::fs::PermissionsExt;
use gatewayd::state::{InFlight, RECENT_KEPT, State, StateError, THREADS_KEPT};
use tmp::TempDir;
const CHAN: &str = "c0000000000000000000000000";
const DM: &str = "d0000000000000000000000000";
fn id(n: usize) -> String {
format!("p{n:025}")
}
fn turn(n: usize) -> InFlight {
InFlight {
session: format!("mm-{}", id(n)),
channel: DM.to_string(),
root: id(n),
}
}
#[test]
fn a_first_start_then_a_restart() {
let dir = TempDir::new("state-restart");
let path = dir.path().join("gateway/state.json");
let mut s = State::load(&path).unwrap();
assert!(!path.exists(), "loading writes nothing");
assert_eq!(
(s.since(CHAN), s.channels().len(), s.seen(&id(1))),
(None, 0, false)
);
s.handled(&id(1), CHAN, 2000).unwrap();
s.handled(&id(2), CHAN, 1500).unwrap();
s.mark(DM, 3000).unwrap();
s.mark(DM, 9000).unwrap();
s.join_thread(&id(1)).unwrap();
s.start_turn(turn(1)).unwrap();
s.start_turn(turn(2)).unwrap();
s.end_turn(&format!("mm-{}", id(2))).unwrap();
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
let dir_mode = std::fs::metadata(path.parent().unwrap())
.unwrap()
.permissions()
.mode()
& 0o777;
assert_eq!((mode, dir_mode), (0o600, 0o700));
assert!(!dir.path().join("gateway/state.json.tmp").exists());
let mut again = State::load(&path).unwrap();
assert_eq!(again.since(CHAN), Some(2000), "the mark never moves back");
assert_eq!(
again.since(DM),
Some(3000),
"mark only sets a channel without one"
);
assert_eq!(again.channels(), [CHAN, DM]);
assert!(again.seen(&id(1)) && again.seen(&id(2)));
assert!(again.knows_thread(&id(1)) && !again.knows_thread(&id(2)));
assert_eq!(again.take_in_flight().unwrap(), [turn(1)]);
assert!(
State::load(&path)
.unwrap()
.take_in_flight()
.unwrap()
.is_empty(),
"taking is saved"
);
}
#[test]
fn only_the_newest_posts_and_threads_are_kept() {
let dir = TempDir::new("state-limits");
// A full file to start from: posts 0.. and threads 0.., at their limits.
let recent: Vec<String> = (0..RECENT_KEPT).map(id).collect();
let threads: Vec<String> = (0..THREADS_KEPT).map(id).collect();
let full =
serde_json::json!({"channels": {}, "recent": recent, "threads": threads, "in_flight": []});
let path = dir.write("state.json", &full.to_string());
let mut s = State::load(&path).unwrap();
s.handled(&id(RECENT_KEPT), CHAN, 1).unwrap();
s.handled(&id(RECENT_KEPT + 1), CHAN, 1).unwrap();
s.handled(&id(RECENT_KEPT + 1), CHAN, 1).unwrap();
s.join_thread(&id(THREADS_KEPT)).unwrap();
s.join_thread(&id(3)).unwrap();
let s = State::load(&path).unwrap();
assert!(!s.seen(&id(0)) && !s.seen(&id(1)) && s.seen(&id(2)) && s.seen(&id(RECENT_KEPT + 1)));
assert!(!s.knows_thread(&id(0)) && s.knows_thread(&id(1)) && s.knows_thread(&id(THREADS_KEPT)));
let text = std::fs::read_to_string(&path).unwrap();
let v: serde_json::Value = serde_json::from_str(&text).unwrap();
assert_eq!(
v["recent"].as_array().unwrap().len(),
RECENT_KEPT,
"a repeat is not stored twice"
);
assert_eq!(v["threads"].as_array().unwrap().len(), THREADS_KEPT);
}
#[test]
fn a_damaged_file_stops_with_its_pointer() {
let dir = TempDir::new("state-damaged");
let good_turn = r#"{"session":"mm-p0000000000000000000000001","channel":"d0000000000000000000000000","root":"p0000000000000000000000001"}"#;
let cases = [
"".to_string(),
"{".to_string(),
"[]".to_string(),
r#"{"channels":{},"recent":[],"threads":[]}"#.to_string(),
r#"{"channels":{},"recent":[],"threads":[],"in_flight":[],"extra":1}"#.to_string(),
r#"{"channels":{"../x":1},"recent":[],"threads":[],"in_flight":[]}"#.to_string(),
r#"{"channels":{},"recent":["short"],"threads":[],"in_flight":[]}"#.to_string(),
r#"{"channels":{},"recent":[],"threads":["P0000000000000000000000000"],"in_flight":[]}"#
.to_string(),
format!(
r#"{{"channels":{{}},"recent":[],"threads":[],"in_flight":[{}]}}"#,
good_turn.replace("mm-", "xx-")
),
format!(
r#"{{"channels":{{}},"recent":[],"threads":[],"in_flight":[{}]}}"#,
good_turn.replace("\"d0", "\"D0")
),
];
for (n, text) in cases.iter().enumerate() {
let path = dir.write(&format!("s{n}.json"), text);
match State::load(&path) {
Err(e @ StateError::Read(..)) => {
let message = e.to_string();
assert!(
message.starts_with(&path.display().to_string()),
"{message}"
);
assert!(
message.ends_with("\nsee docs/runbook.md#gateway-state-damaged"),
"{message}"
);
}
Err(e) => panic!("{text}: {e}"),
Ok(_) => panic!("accepted: {text}"),
}
}
let ok = dir.write(
"ok.json",
&format!(
r#"{{"channels":{{"{CHAN}":5}},"recent":[],"threads":[],"in_flight":[{good_turn}]}}"#
),
);
assert_eq!(State::load(&ok).unwrap().since(CHAN), Some(5));
std::fs::create_dir(dir.path().join("adir")).unwrap();
assert!(matches!(
State::load(&dir.path().join("adir")),
Err(StateError::Read(..))
));
}
#[test]
fn a_failed_write_is_an_error_and_keeps_the_old_file() {
let dir = TempDir::new("state-readonly");
let sub = dir.path().join("gateway");
let path = sub.join("state.json");
let mut s = State::load(&path).unwrap();
s.handled(&id(1), CHAN, 5).unwrap();
let before = std::fs::read(&path).unwrap();
std::fs::set_permissions(&sub, std::fs::Permissions::from_mode(0o500)).unwrap();
let got = s.handled(&id(2), CHAN, 6);
std::fs::set_permissions(&sub, std::fs::Permissions::from_mode(0o700)).unwrap();
let e = got.unwrap_err();
assert!(matches!(e, StateError::Write(..)), "{e}");
assert!(
e.to_string()
.ends_with("see docs/runbook.md#gateway-state-damaged"),
"{e}"
);
assert_eq!(std::fs::read(&path).unwrap(), before);
}
@@ -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 });
})
}
@@ -0,0 +1,241 @@
//! A WebSocket connection against a scripted server, plain and TLS: messages in order, pings both
//! ways, a dead peer, closing, and hostile input (M4a spec, section 6). Do not edit.
#[path = "support/tls_server.rs"]
mod tls_server;
#[path = "support/ws_server.rs"]
mod ws_server;
use std::io::Cursor;
use std::sync::mpsc;
use std::time::{Duration, Instant};
use gatewayd::config::ServerUrl;
use gatewayd::net::Connector;
use gatewayd::ws::WsError;
use gatewayd::ws::conn::{Timing, Ws, host_header};
use tls_server::{fixture, server_config};
use ws_server::{ClientFrame, serve_ws};
const SLOW: Timing = Timing {
ping_every: Duration::from_secs(60),
dead_after: Duration::from_secs(60),
};
/// Plenty of deterministic "random" bytes: the key, then masks.
fn random() -> Box<Cursor<Vec<u8>>> {
Box::new(Cursor::new(
(0..4096u32).map(|i| (i * 37 % 251) as u8).collect(),
))
}
fn open(port: u16, tls: bool, timing: Timing) -> Result<Ws, WsError> {
let url = ServerUrl {
tls,
host: "localhost".to_string(),
port,
};
let ca = tls.then(|| fixture("test-ca.pem"));
let c = Connector::new(url, ca.as_deref()).unwrap();
Ws::open(&c, "TOKEN", timing, random())
}
/// What the server saw, within 5 s: a missing frame fails the test instead of hanging it.
fn got<T>(rx: &mpsc::Receiver<T>) -> T {
rx.recv_timeout(Duration::from_secs(5))
.expect("the server saw nothing within 5 s")
}
/// Poll until a text message or an error, for at most 5 s.
fn next(ws: &mut Ws) -> Result<String, WsError> {
let until = Instant::now() + Duration::from_secs(5);
loop {
if let Some(t) = ws.poll(Duration::from_millis(200))? {
return Ok(t);
}
assert!(Instant::now() < until, "no message within 5 s");
}
}
#[test]
fn messages_arrive_in_order_plain_and_over_tls() {
for tls in [false, true] {
let (tx, rx) = mpsc::channel();
let config = tls.then(|| server_config("server"));
let addr = serve_ws(config, None, move |mut p| {
tx.send(p.request.clone()).unwrap();
p.text("{\"event\":\"hello\"}");
p.send(&[0x01, 0x03, b'o', b'n', b'e']);
p.send(&[0x80, 0x04, b'-', b't', b'w', b'o']);
p.text("three");
p.pause(Duration::from_secs(2));
});
let mut ws = open(addr.port(), tls, SLOW).unwrap();
let request = got(&rx);
assert!(
request.contains("Authorization: Bearer TOKEN\r\n"),
"{request}"
);
assert!(
request.starts_with("GET /api/v4/websocket HTTP/1.1\r\n"),
"{request}"
);
assert!(
request.contains(&format!("Host: localhost:{}\r\n", addr.port())),
"{request}"
);
assert_eq!(next(&mut ws).unwrap(), "{\"event\":\"hello\"}");
assert_eq!(next(&mut ws).unwrap(), "one-two");
assert_eq!(next(&mut ws).unwrap(), "three");
}
}
#[test]
fn a_ping_is_answered_with_the_same_payload() {
let (tx, rx) = mpsc::channel();
let addr = serve_ws(None, None, move |mut p| {
p.frame(0x9, b"are you there");
tx.send(p.read_frame()).unwrap();
p.text("after");
p.pause(Duration::from_secs(2));
});
let mut ws = open(addr.port(), false, SLOW).unwrap();
assert_eq!(next(&mut ws).unwrap(), "after");
let pong: ClientFrame = got(&rx).expect("a pong");
assert_eq!(
(pong.opcode, pong.masked, pong.payload.as_slice()),
(0xA, true, &b"are you there"[..])
);
}
#[test]
fn we_ping_on_schedule_and_every_frame_is_masked_differently() {
let (tx, rx) = mpsc::channel();
let addr = serve_ws(None, None, move |mut p| {
for _ in 0..3 {
tx.send(p.read_frame()).unwrap();
p.frame(0xA, b"");
}
p.pause(Duration::from_secs(2));
});
let timing = Timing {
ping_every: Duration::from_millis(100),
dead_after: Duration::from_secs(5),
};
let mut ws = open(addr.port(), false, timing).unwrap();
ws.send_text("first").unwrap();
let started = Instant::now();
while started.elapsed() < Duration::from_millis(350) {
let _ = ws.poll(Duration::from_millis(50)).unwrap();
}
let frames: Vec<ClientFrame> = (0..3).map(|_| got(&rx).unwrap()).collect();
assert_eq!(
(frames[0].opcode, frames[0].payload.as_slice()),
(0x1, &b"first"[..])
);
assert_eq!(frames[1].opcode, 0x9, "a ping after ping_every");
assert_eq!(frames[2].opcode, 0x9);
assert!(frames.iter().all(|f| f.masked));
assert_ne!(
frames[0].mask, frames[1].mask,
"a fresh mask for every frame"
);
}
#[test]
fn silence_is_a_dead_peer() {
let addr = serve_ws(None, None, |p| p.pause(Duration::from_secs(10)));
let timing = Timing {
ping_every: Duration::from_secs(60),
dead_after: Duration::from_millis(300),
};
let mut ws = open(addr.port(), false, timing).unwrap();
let started = Instant::now();
let err = loop {
match ws.poll(Duration::from_millis(100)) {
Ok(_) => assert!(
started.elapsed() < Duration::from_secs(3),
"never declared dead"
),
Err(e) => break e,
}
};
assert!(matches!(err, WsError::Dead), "{err}");
assert!(started.elapsed() < Duration::from_secs(1));
}
#[test]
fn a_peer_that_trickles_is_alive_and_its_message_arrives() {
let addr = serve_ws(None, None, |mut p| {
for b in [0x81u8, 0x05, b'd', b'r', b'i', b'p', b's'] {
p.send(&[b]);
p.pause(Duration::from_millis(100));
}
p.pause(Duration::from_secs(2));
});
let timing = Timing {
ping_every: Duration::from_secs(60),
dead_after: Duration::from_millis(400),
};
let mut ws = open(addr.port(), false, timing).unwrap();
assert_eq!(next(&mut ws).unwrap(), "drips");
}
#[test]
fn a_close_frame_is_answered_and_ends_the_connection() {
let (tx, rx) = mpsc::channel();
let addr = serve_ws(None, None, move |mut p| {
p.frame(0x8, &[0x03, 0xE8]);
tx.send(p.read_frame()).unwrap();
});
let mut ws = open(addr.port(), false, SLOW).unwrap();
assert!(matches!(next(&mut ws), Err(WsError::Closed)));
let reply = got(&rx).expect("a close in reply");
assert_eq!(
(reply.opcode, reply.payload.as_slice()),
(0x8, &[0x03u8, 0xE8][..])
);
}
#[test]
fn a_dropped_connection_is_closed() {
let addr = serve_ws(None, None, drop);
let mut ws = open(addr.port(), false, SLOW).unwrap();
assert!(matches!(next(&mut ws), Err(WsError::Closed)));
}
#[test]
fn a_hostile_frame_is_an_error_not_a_panic() {
let addr = serve_ws(None, None, |mut p| {
p.send(&[0x81, 0xFF, 0x80, 0, 0, 0, 0, 0, 0, 0]);
p.pause(Duration::from_secs(2));
});
let mut ws = open(addr.port(), false, SLOW).unwrap();
assert!(matches!(next(&mut ws), Err(WsError::Protocol(_))));
}
#[test]
fn a_refused_handshake() {
let addr = serve_ws(
None,
Some("HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\n\r\n"),
|_| {},
);
assert!(matches!(
open(addr.port(), false, SLOW),
Err(WsError::Handshake(_))
));
}
#[test]
fn the_host_header_names_the_port_only_when_it_is_not_the_default() {
let url = |tls, port| ServerUrl {
tls,
host: "chat.example".to_string(),
port,
};
assert_eq!(host_header(&url(true, 443)), "chat.example");
assert_eq!(host_header(&url(false, 80)), "chat.example");
assert_eq!(host_header(&url(true, 80)), "chat.example:80");
assert_eq!(host_header(&url(false, 8065)), "chat.example:8065");
}
@@ -0,0 +1,469 @@
//! WebSocket frames, adversarially (M4a spec, section 6). Everything a server sends is untrusted:
//! each hostile frame must end the connection with an error, never a panic, and a length must be
//! refused before anything waits for or allocates its payload. A seeded property test compares the
//! decoder with a deliberately naive one written here, on valid streams and on random mutations of
//! them, fed in random pieces. The seed is printed on failure. Do not edit.
use gatewayd::ws::WsError;
use gatewayd::ws::frame::{
CLOSE, CONTINUATION, Decoder, Incoming, MAX_MESSAGE, PING, PONG, TEXT, encode,
};
/// How a server frame's length is written: the shortest form, or a longer one on purpose.
#[derive(Clone, Copy)]
enum Len {
Short,
Force16,
Force64,
}
/// A frame as a server sends it (unmasked unless `masked`).
fn frame(fin: bool, rsv: u8, opcode: u8, masked: bool, payload: &[u8], form: Len) -> Vec<u8> {
let mut out = vec![(if fin { 0x80 } else { 0 }) | (rsv << 4) | opcode];
let m = if masked { 0x80 } else { 0 };
let len = payload.len();
match form {
Len::Short if len < 126 => out.push(m | len as u8),
Len::Short if len <= 0xFFFF => {
out.push(m | 126);
out.extend_from_slice(&(len as u16).to_be_bytes());
}
Len::Force16 => {
out.push(m | 126);
out.extend_from_slice(&(len as u16).to_be_bytes());
}
_ => {
out.push(m | 127);
out.extend_from_slice(&(len as u64).to_be_bytes());
}
}
if masked {
out.extend_from_slice(&[1, 2, 3, 4]);
}
out.extend_from_slice(payload);
out
}
fn text(s: &str) -> Vec<u8> {
frame(true, 0, TEXT, false, s.as_bytes(), Len::Short)
}
/// Feed `bytes` in pieces of `step` and collect every message, stopping at the first error.
fn decode(bytes: &[u8], step: usize) -> (Vec<Incoming>, Option<String>) {
let mut d = Decoder::new();
let mut got = Vec::new();
for piece in bytes.chunks(step.max(1)) {
d.feed(piece);
loop {
match d.next_message() {
Ok(Some(m)) => got.push(m),
Ok(None) => break,
Err(e) => return (got, Some(e.to_string())),
}
}
}
(got, None)
}
fn fails(bytes: &[u8], why: &str) {
for step in [1, 2, 3, 7, bytes.len().max(1)] {
let (_, err) = decode(bytes, step);
assert!(err.is_some(), "{why} (fed {step} at a time) was accepted");
}
}
#[test]
fn plain_messages() {
let mut bytes = text("hello");
bytes.extend(frame(true, 0, PING, false, b"p1", Len::Short));
bytes.extend(frame(true, 0, PONG, false, b"", Len::Short));
bytes.extend(frame(
true,
0,
CLOSE,
false,
&[0x03, 0xE8, b'b', b'y', b'e'],
Len::Short,
));
let (got, err) = decode(&bytes, bytes.len());
assert_eq!(err, None);
assert_eq!(
got,
[
Incoming::Text("hello".into()),
Incoming::Ping(b"p1".to_vec()),
Incoming::Pong(Vec::new()),
Incoming::Close(Some(1000), "bye".into()),
]
);
assert_eq!(
decode(&frame(true, 0, CLOSE, false, b"", Len::Short), 1).0,
[Incoming::Close(None, String::new())]
);
}
#[test]
fn fragments_reassemble_with_control_frames_between_and_utf8_split_across_them() {
let snow = "snow ☃ man";
let bytes_of = snow.as_bytes();
let cut = snow.find('☃').unwrap() + 1; // inside the three-byte character
let mut bytes = frame(false, 0, TEXT, false, &bytes_of[..cut], Len::Short);
bytes.extend(frame(true, 0, PING, false, b"mid", Len::Short));
bytes.extend(frame(
false,
0,
CONTINUATION,
false,
&bytes_of[cut..cut + 1],
Len::Short,
));
bytes.extend(frame(
true,
0,
CONTINUATION,
false,
&bytes_of[cut + 1..],
Len::Short,
));
for step in 1..=bytes.len() {
let (got, err) = decode(&bytes, step);
assert_eq!(err, None, "step {step}");
assert_eq!(
got,
[Incoming::Ping(b"mid".to_vec()), Incoming::Text(snow.into())],
"step {step}"
);
}
}
#[test]
fn lengths_in_every_form() {
for len in [0usize, 1, 125, 126, 127, 65_535, 65_536, 100_000] {
let body = "x".repeat(len);
let (got, err) = decode(&text(&body), 4096);
assert_eq!(err, None, "{len}");
assert_eq!(got, [Incoming::Text(body)], "{len}");
}
}
#[test]
fn hostile_frames_end_the_connection() {
for rsv in [1, 2, 4] {
fails(
&frame(true, rsv, TEXT, false, b"x", Len::Short),
"a reserved bit",
);
}
fails(
&frame(true, 0, TEXT, true, b"x", Len::Short),
"a masked frame from the server",
);
for op in [2u8, 3, 7, 11, 15] {
fails(
&frame(true, 0, op, false, b"x", Len::Short),
"an unknown or binary opcode",
);
}
fails(
&frame(true, 0, PING, false, &[0u8; 126], Len::Short),
"a control frame over 125 bytes",
);
fails(
&frame(false, 0, PING, false, b"x", Len::Short),
"a fragmented control frame",
);
fails(
&frame(true, 0, CONTINUATION, false, b"x", Len::Short),
"a continuation with nothing to continue",
);
let mut inside = frame(false, 0, TEXT, false, b"a", Len::Short);
inside.extend(text("b"));
fails(&inside, "a new message inside an unfinished one");
fails(
&frame(true, 0, TEXT, false, b"x", Len::Force16),
"a 16-bit length for 1 byte",
);
fails(
&frame(true, 0, TEXT, false, &[b'y'; 200], Len::Force64),
"a 64-bit length for 200 bytes",
);
fails(
&frame(true, 0, TEXT, false, &[0xff, 0xfe], Len::Short),
"text that is not UTF-8",
);
fails(
&frame(true, 0, CLOSE, false, &[3], Len::Short),
"a close frame of one byte",
);
fails(
&frame(true, 0, CLOSE, false, &[3, 232, 0xff], Len::Short),
"a close reason that is not UTF-8",
);
}
#[test]
fn huge_lengths_are_refused_from_the_header_alone() {
// Only the header is fed: the decoder must refuse without waiting for a payload.
let top_bit = [0x81u8, 127, 0x80, 0, 0, 0, 0, 0, 0, 1];
let mut d = Decoder::new();
d.feed(&top_bit);
assert!(
d.next_message().is_err(),
"a 64-bit length with its top bit set"
);
let too_big = (MAX_MESSAGE as u64) + 1;
let mut head = vec![0x81u8, 127];
head.extend_from_slice(&too_big.to_be_bytes());
let mut d = Decoder::new();
d.feed(&head);
assert!(matches!(d.next_message(), Err(WsError::TooLarge)));
let mut head = vec![0x81u8, 127];
head.extend_from_slice(&0x7FFF_FFFF_FFFF_FFFFu64.to_be_bytes());
let mut d = Decoder::new();
d.feed(&head);
assert!(matches!(d.next_message(), Err(WsError::TooLarge)));
// Across fragments: the sum counts.
let half = MAX_MESSAGE / 2 + 1;
let mut d = Decoder::new();
d.feed(&frame(false, 0, TEXT, false, &vec![b'a'; half], Len::Short));
assert!(matches!(d.next_message(), Ok(None)));
let mut second = vec![0x00u8, 127];
second.extend_from_slice(&(half as u64).to_be_bytes());
d.feed(&second);
assert!(matches!(d.next_message(), Err(WsError::TooLarge)));
// Exactly the limit is fine.
let (got, err) = decode(&text(&"z".repeat(MAX_MESSAGE)), 65_536);
assert_eq!(err, None);
assert_eq!(got.len(), 1);
}
#[test]
fn our_frames_are_masked_and_decode_back() {
let mask = [0x11, 0x22, 0x33, 0x44];
for len in [0usize, 5, 125, 126, 65_535, 65_536] {
let payload: Vec<u8> = (0..len).map(|i| (i % 251) as u8).collect();
let bytes = encode(TEXT, &payload, mask);
assert_eq!(bytes[0], 0x80 | TEXT, "FIN and the opcode");
assert_ne!(bytes[1] & 0x80, 0, "the mask bit");
let (len_field, header) = match bytes[1] & 0x7F {
126 => (u16::from_be_bytes([bytes[2], bytes[3]]) as usize, 4),
127 => (
u64::from_be_bytes(bytes[2..10].try_into().unwrap()) as usize,
10,
),
n => (n as usize, 2),
};
assert_eq!(len_field, len);
let shortest = if len < 126 {
2
} else if len <= 0xFFFF {
4
} else {
10
};
assert_eq!(header, shortest, "the shortest length form");
assert_eq!(&bytes[header..header + 4], &mask);
let unmasked: Vec<u8> = bytes[header + 4..]
.iter()
.zip(mask.iter().cycle())
.map(|(b, m)| b ^ m)
.collect();
assert_eq!(unmasked, payload);
}
assert_eq!(encode(PONG, b"p", mask)[0], 0x80 | PONG);
}
// ---------- the property test ----------
struct Rng(u64);
impl Rng {
fn next(&mut self) -> u64 {
let mut x = self.0;
x ^= x << 13;
x ^= x >> 7;
x ^= x << 17;
self.0 = x;
x
}
fn below(&mut self, n: usize) -> usize {
(self.next() % n.max(1) as u64) as usize
}
}
/// The naive decoder: the whole buffer at once, the rules written out plainly.
fn naive(bytes: &[u8]) -> (Vec<Incoming>, bool) {
let mut out = Vec::new();
let mut i = 0usize;
let mut partial: Option<Vec<u8>> = None;
while i < bytes.len() {
if bytes.len() - i < 2 {
return (out, false);
}
let (b0, b1) = (bytes[i], bytes[i + 1]);
let (fin, rsv, op, masked, short) = (
b0 >> 7 == 1,
(b0 >> 4) & 7,
b0 & 15,
b1 >> 7 == 1,
(b1 & 127) as usize,
);
if rsv != 0 || masked || ![0, 1, 8, 9, 10].contains(&op) {
return (out, true);
}
let (hl, len) = if short == 126 {
if bytes.len() - i < 4 {
return (out, false);
}
let l = u16::from_be_bytes([bytes[i + 2], bytes[i + 3]]) as usize;
if l < 126 {
return (out, true);
}
(4, l)
} else if short == 127 {
if bytes.len() - i < 10 {
return (out, false);
}
let l = u64::from_be_bytes(bytes[i + 2..i + 10].try_into().unwrap());
if l >> 63 == 1 || l <= 0xFFFF {
return (out, true);
}
(10, l as usize)
} else {
(2, short)
};
let control = op >= 8;
if control && (!fin || len > 125) {
return (out, true);
}
if !control {
if (op == 1 && partial.is_some()) || (op == 0 && partial.is_none()) {
return (out, true);
}
if partial.as_ref().map_or(0, |p| p.len()) + len > MAX_MESSAGE {
return (out, true);
}
}
if bytes.len() - i - hl < len {
return (out, false);
}
let payload = bytes[i + hl..i + hl + len].to_vec();
i += hl + len;
match op {
9 => out.push(Incoming::Ping(payload)),
10 => out.push(Incoming::Pong(payload)),
8 => {
if payload.len() == 1 {
return (out, true);
}
if payload.is_empty() {
out.push(Incoming::Close(None, String::new()));
} else {
match String::from_utf8(payload[2..].to_vec()) {
Ok(r) => out.push(Incoming::Close(
Some(u16::from_be_bytes([payload[0], payload[1]])),
r,
)),
Err(_) => return (out, true),
}
}
}
_ => {
let mut m = if op == 1 {
Vec::new()
} else {
partial.take().unwrap()
};
m.extend_from_slice(&payload);
if fin {
match String::from_utf8(m) {
Ok(t) => out.push(Incoming::Text(t)),
Err(_) => return (out, true),
}
} else {
partial = Some(m);
}
}
}
}
(out, false)
}
/// A random valid stream: text messages split into random fragments, with control frames between.
fn valid_stream(rng: &mut Rng) -> Vec<u8> {
let mut bytes = Vec::new();
for _ in 0..1 + rng.below(6) {
let len = [0, 1, 50, 125, 126, 300, 70_000][rng.below(7)];
let body: String = (0..len)
.map(|k| {
if (k + rng.below(3)).is_multiple_of(29) {
'é'
} else {
'a'
}
})
.collect();
let raw = body.as_bytes();
let parts = 1 + rng.below(3);
let mut cuts: Vec<usize> = (0..parts - 1).map(|_| rng.below(raw.len() + 1)).collect();
cuts.sort();
let mut start = 0;
for (k, cut) in cuts
.iter()
.copied()
.chain(std::iter::once(raw.len()))
.enumerate()
{
let op = if k == 0 { TEXT } else { CONTINUATION };
bytes.extend(frame(
k == parts - 1,
0,
op,
false,
&raw[start..cut],
Len::Short,
));
start = cut;
if rng.below(3) == 0 {
bytes.extend(frame(
true,
0,
PING,
false,
&[rng.below(256) as u8; 3],
Len::Short,
));
}
}
}
bytes
}
#[test]
fn random_streams_agree_with_the_naive_decoder() {
for case in 0..300u64 {
let seed = 0x9E37_79B9_7F4A_7C15 ^ (case * 7919 + 1);
let mut rng = Rng(seed);
let mut bytes = valid_stream(&mut rng);
if case % 2 == 1 {
// Mutate: flip a few random bits, so most streams break somewhere different.
for _ in 0..1 + rng.below(4) {
let at = rng.below(bytes.len());
bytes[at] ^= 1 << rng.below(8);
}
}
let (want, want_err) = naive(&bytes);
let step = 1 + rng.below(4096);
let (got, got_err) = decode(&bytes, step);
assert_eq!(got, want, "seed {seed:#x}, step {step}: messages differ");
assert_eq!(
got_err.is_some(),
want_err,
"seed {seed:#x}, step {step}: {got_err:?} vs naive error {want_err}"
);
}
}
@@ -0,0 +1,200 @@
//! Base64 and the WebSocket opening handshake (RFC 4648; RFC 6455, section 4). Do not edit.
use std::io::{Cursor, Read, Write};
use gatewayd::http::Head;
use gatewayd::ws::WsError;
use gatewayd::ws::handshake::{
accept_for, base64, check_response, handshake, new_key, request_text,
};
#[test]
fn base64_vectors() {
for (input, want) in [
("", ""),
("f", "Zg=="),
("fo", "Zm8="),
("foo", "Zm9v"),
("foob", "Zm9vYg=="),
("fooba", "Zm9vYmE="),
("foobar", "Zm9vYmFy"),
] {
assert_eq!(base64(input.as_bytes()), want, "{input:?}");
}
assert_eq!(base64(&[0xff, 0xfe, 0xfd, 0x00, 0x3f]), "//79AD8=");
assert_eq!(
base64(&(0u8..=15).collect::<Vec<_>>()),
"AAECAwQFBgcICQoLDA0ODw=="
);
}
#[test]
fn the_rfc_example_accept() {
assert_eq!(
accept_for("dGhlIHNhbXBsZSBub25jZQ=="),
"s3pPLMBiTxaQ9kYGzzhZRbK+xOo="
);
}
#[test]
fn a_key_is_sixteen_random_bytes() {
let mut random = Cursor::new((0u8..=15).collect::<Vec<_>>());
assert_eq!(new_key(&mut random).unwrap(), "AAECAwQFBgcICQoLDA0ODw==");
let mut short = Cursor::new(vec![1u8; 15]);
assert!(
new_key(&mut short).is_err(),
"too few random bytes is an error, not a weak key"
);
}
#[test]
fn the_request_is_exactly_this() {
assert_eq!(
request_text("a.example", "/api/v4/websocket", "KEY==", "TOKEN"),
"GET /api/v4/websocket HTTP/1.1\r\nHost: a.example\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"
);
}
fn head(status: u16, headers: &[(&str, &str)]) -> Head {
Head {
status,
headers: headers
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect(),
}
}
const KEY: &str = "dGhlIHNhbXBsZSBub25jZQ==";
const ACCEPT: &str = "s3pPLMBiTxaQ9kYGzzhZRbK+xOo=";
#[test]
fn only_a_proper_upgrade_is_accepted() {
let good = [
("Upgrade", "websocket"),
("Connection", "Upgrade"),
("Sec-WebSocket-Accept", ACCEPT),
];
assert!(check_response(&head(101, &good), KEY).is_ok());
let loose = [
("upgrade", "WebSocket"),
("connection", "keep-alive, Upgrade"),
("sec-websocket-accept", ACCEPT),
];
assert!(
check_response(&head(101, &loose), KEY).is_ok(),
"names and tokens are case-insensitive"
);
let cases: [(u16, &[(&str, &str)]); 7] = [
(200, &good),
(401, &good),
(
101,
&[("Connection", "Upgrade"), ("Sec-WebSocket-Accept", ACCEPT)],
),
(
101,
&[
("Upgrade", "h2c"),
("Connection", "Upgrade"),
("Sec-WebSocket-Accept", ACCEPT),
],
),
(
101,
&[("Upgrade", "websocket"), ("Sec-WebSocket-Accept", ACCEPT)],
),
(101, &[("Upgrade", "websocket"), ("Connection", "Upgrade")]),
(
101,
&[
("Upgrade", "websocket"),
("Connection", "Upgrade"),
("Sec-WebSocket-Accept", "s3pplmbitxaq9kygzzhzrbk+xoo="),
],
),
];
for (status, headers) in cases {
assert!(
matches!(
check_response(&head(status, headers), KEY),
Err(WsError::Handshake(_))
),
"{status} {headers:?}"
);
}
}
/// A server side scripted as bytes; records what the client wrote.
struct Scripted {
input: Cursor<Vec<u8>>,
output: Vec<u8>,
}
impl Read for Scripted {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
self.input.read(buf)
}
}
impl Write for Scripted {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.output.write(buf)
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
#[test]
fn a_whole_handshake_leaves_the_first_frame_unread() {
let key_bytes: Vec<u8> = (0u8..=15).collect();
let key = base64(&key_bytes);
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 mut bytes = reply.into_bytes();
bytes.extend_from_slice(&[0x81, 0x02, b'h', b'i']);
let mut s = Scripted {
input: Cursor::new(bytes),
output: Vec::new(),
};
handshake(
&mut s,
"a.example",
"/api/v4/websocket",
"TOKEN",
&mut Cursor::new(key_bytes),
)
.unwrap();
assert_eq!(
String::from_utf8(s.output).unwrap(),
request_text("a.example", "/api/v4/websocket", &key, "TOKEN")
);
let mut rest = Vec::new();
s.input.read_to_end(&mut rest).unwrap();
assert_eq!(rest, [0x81, 0x02, b'h', b'i']);
}
#[test]
fn a_refused_or_broken_handshake_is_a_handshake_error() {
for reply in [
&b"HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\n\r\n"[..],
b"HTTP/1.1 101 Switching",
b"not http at all\r\n\r\n",
b"",
] {
let mut s = Scripted {
input: Cursor::new(reply.to_vec()),
output: Vec::new(),
};
let got = handshake(&mut s, "h", "/p", "t", &mut Cursor::new(vec![7u8; 16]));
assert!(
matches!(got, Err(WsError::Handshake(_))),
"{:?}: {got:?}",
String::from_utf8_lossy(reply)
);
}
}
@@ -0,0 +1,65 @@
//! SHA-1 (FIPS 180-4), used only to check the `Sec-WebSocket-Accept` header of a WebSocket
//! handshake (RFC 6455, section 4.2.2). Never use it for anything that needs to resist attack.
/// The digest of `data`.
pub fn sha1(data: &[u8]) -> [u8; 20] {
let mut h = Sha1::new();
h.update(data);
h.finish()
}
/// SHA-1 fed in pieces.
#[derive(Debug, Clone)]
pub struct Sha1 {
state: [u32; 5],
block: [u8; 64],
filled: usize,
length: u64,
}
impl Default for Sha1 {
fn default() -> Self {
Self::new()
}
}
impl Sha1 {
pub fn new() -> Sha1 {
Sha1 {
state: [
0x6745_2301,
0xEFCD_AB89,
0x98BA_DCFE,
0x1032_5476,
0xC3D2_E1F0,
],
block: [0; 64],
filled: 0,
length: 0,
}
}
pub fn update(&mut self, mut data: &[u8]) {
// Add 8 * data.len() to `length` (wrapping; `u64::try_from`, never `as`). Copy bytes into
// `block` from `filled` on; each time it is full (64), `compress` it and set `filled` to 0.
// Use `split_at` and `get_mut(..)`, no indexing that can go out of bounds.
todo!()
}
pub fn finish(mut self) -> [u8; 20] {
// Save `length`. Feed 0x80 then zeros so that 56 bytes of the block are filled (56 -
// filled, or 120 - filled when filled >= 56), then the saved length as 8 big-endian bytes,
// through `update`. `update` adds to `length`: put the saved value back after. Then the
// five state words, big-endian.
todo!()
}
fn compress(&mut self, block: &[u8; 64]) {
// FIPS 180-4, section 6.1.2: w[0..16] are the block as big-endian u32s; w[i] = (w[i-3] ^
// w[i-8] ^ w[i-14] ^ w[i-16]).rotate_left(1) for 16..80. Eighty rounds with f and k by
// range: 0..=19 (b & c) | (!b & d), 0x5A827999; 20..=39 b ^ c ^ d, 0x6ED9EBA1; 40..=59 (b &
// c) | (b & d) | (c & d), 0x8F1BBCDC; 60..=79 b ^ c ^ d, 0xCA62C1D6. All additions
// wrapping. Add a..e into state.
todo!()
}
}
@@ -0,0 +1,78 @@
//! SHA-1 against FIPS 180 and RFC 3174 vectors, and against `sha1sum` for the lengths around the
//! 64-byte block where padding changes shape. Every case is also fed in pieces. Do not edit.
use proto::sha1::{Sha1, sha1};
fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
fn check(data: &[u8], want: &str) {
assert_eq!(hex(&sha1(data)), want, "whole, {} bytes", data.len());
for split in [
0,
1,
data.len() / 2,
data.len().saturating_sub(1),
data.len(),
] {
let split = split.min(data.len());
let mut h = Sha1::new();
h.update(&data[..split]);
h.update(&data[split..]);
assert_eq!(hex(&h.finish()), want, "split at {split} of {}", data.len());
}
let mut h = Sha1::new();
for b in data {
h.update(&[*b]);
}
assert_eq!(hex(&h.finish()), want, "byte by byte, {} bytes", data.len());
}
#[test]
fn standard_vectors() {
check(b"", "da39a3ee5e6b4b0d3255bfef95601890afd80709");
check(b"abc", "a9993e364706816aba3e25717850c26c9cd0d89d");
check(
b"abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq",
"84983e441c3bd26ebaae4aa1f95129e5e54670f1",
);
check(
b"The quick brown fox jumps over the lazy dog",
"2fd4e1c67a2d28fced849ee1bb76e7391b93eb12",
);
}
#[test]
fn a_million_a() {
let data = vec![b'a'; 1_000_000];
assert_eq!(
hex(&sha1(&data)),
"34aa973cd4c4daa4f61eeb2bdbad27316534016f"
);
}
#[test]
fn lengths_around_the_block_boundary() {
// `printf 'a%.0s' $(seq N) | sha1sum`, N = 55, 56, 63, 64, 65, 119, 120.
let cases = [
(55, "c1c8bbdc22796e28c0e15163d20899b65621d65a"),
(56, "c2db330f6083854c99d4b5bfb6e8f29f201be699"),
(63, "03f09f5b158a7a8cdad920bddc29b81c18a551f5"),
(64, "0098ba824b5c16427bd7a1122a5a442a25ec644d"),
(65, "11655326c708d70319be2610e8a57d9a5b959d3b"),
(119, "ee971065aaa017e0632a8ca6c77bb3bf8b1dfc56"),
(120, "f34c1488385346a55709ba056ddd08280dd4c6d6"),
];
for (n, want) in cases {
check(&vec![b'a'; n], want);
}
}
#[test]
fn the_websocket_handshake_example() {
// RFC 6455, section 1.3: the key and the GUID give this digest (its base64 is
// "s3pPLMBiTxaQ9kYGzzhZRbK+xOo=").
let digest = sha1(b"dGhlIHNhbXBsZSBub25jZQ==258EAFA5-E914-47DA-95CA-C5AB0DC85B11");
assert_eq!(hex(&digest), "b37a4f2cc0624f1690f64606cf385945b2bec4ea");
}