Plan M4a: gatewayd in 15 tasks, with skeletons and given tests
Each task's tests were run against a reference at its end state; the end states were replayed from master in order with the gate at each step (650 to 762 tests); each skeleton compiles against its tests and fails them. The reference is kept off this machine. Lessons T27 (every wait in a test has a limit) and T28 (mutate the reference before hand-over) come from this work. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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(¬_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");
|
||||
}
|
||||
Reference in New Issue
Block a user