Merge M4a: gatewayd, conversations over Mattermost
Sixteen tasks by Ornith through the plan driver, the review's fixes, and task 16 for its one low finding. Checked end to end against the owner's Mattermost. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Generated
+289
@@ -2,6 +2,12 @@
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
|
||||
|
||||
[[package]]
|
||||
name = "brokerd"
|
||||
version = "0.1.0"
|
||||
@@ -20,6 +26,38 @@ dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.4.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "54413ede23c2daf518f35156dfde027feb2374004d63bd497f983c8db9c0e313"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"shlex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation-sys"
|
||||
version = "0.8.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||
|
||||
[[package]]
|
||||
name = "emsha"
|
||||
version = "1.0.4"
|
||||
@@ -32,11 +70,34 @@ version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ef25905e51abafe4dcea6c15fec58c57b601cdbd0ee53d22ea1d3016c587d39b"
|
||||
|
||||
[[package]]
|
||||
name = "gatewayd"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"proto",
|
||||
"rustls",
|
||||
"rustls-native-certs",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"toml",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"wasi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -74,6 +135,12 @@ version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.189"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
||||
|
||||
[[package]]
|
||||
name = "loopd"
|
||||
version = "0.1.0"
|
||||
@@ -90,6 +157,18 @@ version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-probe"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
@@ -119,6 +198,98 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"getrandom",
|
||||
"libc",
|
||||
"untrusted",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-native-certs"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d"
|
||||
dependencies = [
|
||||
"openssl-probe",
|
||||
"rustls-pki-types",
|
||||
"schannel",
|
||||
"security-framework",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
|
||||
dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"untrusted",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schannel"
|
||||
version = "0.1.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "3.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"core-foundation",
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
"security-framework-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework-sys"
|
||||
version = "2.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
@@ -171,6 +342,18 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.6"
|
||||
@@ -236,12 +419,118 @@ version = "1.0.26"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.11.1+wasi-snapshot-preview1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.52.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
||||
dependencies = [
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm",
|
||||
"windows_aarch64_msvc",
|
||||
"windows_i686_gnu",
|
||||
"windows_i686_gnullvm",
|
||||
"windows_i686_msvc",
|
||||
"windows_x86_64_gnu",
|
||||
"windows_x86_64_gnullvm",
|
||||
"windows_x86_64_msvc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
|
||||
|
||||
[[package]]
|
||||
name = "zeroize"
|
||||
version = "1.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
|
||||
@@ -25,4 +25,7 @@ serde_json = "1.0.151"
|
||||
humantime = "2.4.0"
|
||||
toml = "1.1.6"
|
||||
emsha = "1.0.4"
|
||||
rustls = { version = "0.23.45", default-features = false, features = ["ring", "std", "tls12"] }
|
||||
rustls-native-certs = "0.8.4"
|
||||
zeroize = "1.9.0"
|
||||
|
||||
|
||||
@@ -10,3 +10,12 @@ workspace = true
|
||||
|
||||
[dependencies]
|
||||
proto.workspace = true
|
||||
rustls.workspace = true
|
||||
rustls-native-certs.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
toml.workspace = true
|
||||
zeroize.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
rustls.workspace = true
|
||||
|
||||
@@ -0,0 +1,364 @@
|
||||
//! `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()`.
|
||||
match self {
|
||||
ConfigError::Read(path, e) => write!(f, "{}: {}", path.display(), e),
|
||||
ConfigError::Parse(path, e) => write!(f, "{}: {}", path.display(), e),
|
||||
ConfigError::Invalid(path, why) => write!(f, "{}: {}", path.display(), why),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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`.
|
||||
toml::from_str(text)
|
||||
}
|
||||
|
||||
pub fn load(path: &Path) -> Result<Config, ConfigError> {
|
||||
// Read the file (else Read), parse (else Parse), then `problem()` (Some(why) is Invalid).
|
||||
let text =
|
||||
std::fs::read_to_string(path).map_err(|e| ConfigError::Read(path.to_path_buf(), e))?;
|
||||
let config = Config::parse(&text).map_err(|e| ConfigError::Parse(path.to_path_buf(), e))?;
|
||||
match config.problem() {
|
||||
Some(why) => Err(ConfigError::Invalid(path.to_path_buf(), why)),
|
||||
None => Ok(config),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
if let Err(why) = parse_url(&self.mattermost.url) {
|
||||
return Some(why);
|
||||
}
|
||||
if let Some(ca_file) = &self.mattermost.ca_file
|
||||
&& !ca_file.is_absolute()
|
||||
{
|
||||
return Some(format!(
|
||||
"[mattermost] ca_file {:?} must be an absolute path",
|
||||
ca_file.display()
|
||||
));
|
||||
}
|
||||
if !self.secrets.contains_key(MATTERMOST_TOKEN) {
|
||||
return Some("[secrets.mattermost_token] is missing".to_string());
|
||||
}
|
||||
for (name, spec) in &self.secrets {
|
||||
if let Err(why) = spec.source() {
|
||||
return Some(format!("[secrets.{}] {}", name, why));
|
||||
}
|
||||
}
|
||||
if self.allow.users.is_empty() {
|
||||
return Some(
|
||||
"[allow] users is empty: a gateway that answers nobody is a mistake".to_string(),
|
||||
);
|
||||
}
|
||||
for id in self.allow.users.iter().chain(self.allow.channels.iter()) {
|
||||
if !valid_id(id) {
|
||||
return Some(format!(
|
||||
"[allow] {:?} is not a Mattermost id (26 characters of a-z and 0-9)",
|
||||
id
|
||||
));
|
||||
}
|
||||
}
|
||||
if self.limits.queue == 0 {
|
||||
return Some("[limits] queue must be at least 1".to_string());
|
||||
}
|
||||
for (name, value) in [
|
||||
("typing_every_ms", self.limits.typing_every_ms),
|
||||
("ping_every_ms", self.limits.ping_every_ms),
|
||||
("dead_after_ms", self.limits.dead_after_ms),
|
||||
] {
|
||||
if value == 0 {
|
||||
return Some(format!("[limits] {} must be at least 1", name));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The server's address, from `url`. Call only on a checked `Config`.
|
||||
pub fn server(&self) -> Result<ServerUrl, String> {
|
||||
// `parse_url` of the url.
|
||||
parse_url(&self.mattermost.url)
|
||||
}
|
||||
|
||||
pub fn token_source(&self) -> Result<SecretSource, String> {
|
||||
// The `source()` of [secrets.mattermost_token], or an error if it is missing.
|
||||
match self.secrets.get(MATTERMOST_TOKEN) {
|
||||
Some(spec) => spec.source(),
|
||||
None => Err("[secrets.mattermost_token] is missing".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn loop_socket(&self) -> PathBuf {
|
||||
// [loop] socket, or <home>/run/loop/loop.sock when it is empty.
|
||||
if self.loop_.socket.as_os_str().is_empty() {
|
||||
return self.paths.home.join("run/loop/loop.sock");
|
||||
}
|
||||
self.loop_.socket.clone()
|
||||
}
|
||||
|
||||
pub fn state_path(&self) -> PathBuf {
|
||||
// <home>/gateway/state.json.
|
||||
self.paths.home.join("gateway/state.json")
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
let mut count = 0;
|
||||
if self.credential.is_some() {
|
||||
count += 1;
|
||||
}
|
||||
if self.env.is_some() {
|
||||
count += 1;
|
||||
}
|
||||
if self.file.is_some() {
|
||||
count += 1;
|
||||
}
|
||||
if count != 1 {
|
||||
return Err("needs exactly one of credential, env and file".to_string());
|
||||
}
|
||||
if let Some(credential) = &self.credential {
|
||||
if credential.is_empty()
|
||||
|| !credential.bytes().all(|b| {
|
||||
b.is_ascii_alphabetic() || b.is_ascii_digit() || matches!(b, b'_' | b'.' | b'-')
|
||||
})
|
||||
{
|
||||
return Err(format!(
|
||||
"credential {:?} is not a credential name (letters, digits, _ . -)",
|
||||
credential
|
||||
));
|
||||
}
|
||||
return Ok(SecretSource::Credential(credential.clone()));
|
||||
}
|
||||
if let Some(env) = &self.env {
|
||||
if env.is_empty()
|
||||
|| !env
|
||||
.bytes()
|
||||
.all(|b| matches!(b, b'A'..=b'Z' | b'0'..=b'9' | b'_'))
|
||||
{
|
||||
return Err(format!(
|
||||
"env {:?} is not a variable name (A-Z, 0-9, _)",
|
||||
env
|
||||
));
|
||||
}
|
||||
return Ok(SecretSource::Env(env.clone()));
|
||||
}
|
||||
// `count == 1` and the two above are `None`, so this is `Some`.
|
||||
let file = match &self.file {
|
||||
Some(file) => file,
|
||||
None => return Err("needs exactly one of credential, env and file".to_string()),
|
||||
};
|
||||
if !file.is_absolute() {
|
||||
return Err(format!(
|
||||
"file {:?} must be an absolute path",
|
||||
file.display()
|
||||
));
|
||||
}
|
||||
Ok(SecretSource::File(file.clone()))
|
||||
}
|
||||
}
|
||||
|
||||
/// A Mattermost id: 26 characters of `a-z0-9`.
|
||||
pub fn valid_id(id: &str) -> bool {
|
||||
// 26 bytes, each a-z or 0-9.
|
||||
id.len() == 26
|
||||
&& id
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_digit() || b.is_ascii_lowercase())
|
||||
}
|
||||
|
||||
/// `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.
|
||||
let (tls, rest) = match url.strip_prefix("https://") {
|
||||
Some(rest) => (true, rest),
|
||||
None => match url.strip_prefix("http://") {
|
||||
Some(rest) => (false, rest),
|
||||
None => return Err(url_error(url)),
|
||||
},
|
||||
};
|
||||
let (host, port) = match rest.rsplit_once(':') {
|
||||
Some((h, p)) => {
|
||||
if !valid_host(h) {
|
||||
return Err(url_error(url));
|
||||
}
|
||||
let port = match parse_port(p) {
|
||||
Some(port) => port,
|
||||
None => return Err(url_error(url)),
|
||||
};
|
||||
(h.to_string(), port)
|
||||
}
|
||||
None => {
|
||||
if !valid_host(rest) {
|
||||
return Err(url_error(url));
|
||||
}
|
||||
(rest.to_string(), if tls { 443 } else { 80 })
|
||||
}
|
||||
};
|
||||
Ok(ServerUrl { tls, host, port })
|
||||
}
|
||||
|
||||
fn url_error(url: &str) -> String {
|
||||
format!(
|
||||
"[mattermost] url {:?} must be http:// or https://, a host, an optional port, and nothing else",
|
||||
url
|
||||
)
|
||||
}
|
||||
|
||||
fn valid_host(host: &str) -> bool {
|
||||
let bytes = host.as_bytes();
|
||||
let len = bytes.len();
|
||||
if len == 0 || len > 253 {
|
||||
return false;
|
||||
}
|
||||
if bytes[0] == b'.' || bytes[0] == b'-' || bytes[len - 1] == b'.' || bytes[len - 1] == b'-' {
|
||||
return false;
|
||||
}
|
||||
bytes
|
||||
.iter()
|
||||
.all(|&b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'.' || b == b'-')
|
||||
}
|
||||
|
||||
fn parse_port(p: &str) -> Option<u16> {
|
||||
if p.is_empty() || !p.bytes().all(|b| b.is_ascii_digit()) {
|
||||
return None;
|
||||
}
|
||||
let n = p.parse::<u32>().ok()?;
|
||||
if !(1..=65535).contains(&n) || n.to_string() != p {
|
||||
return None;
|
||||
}
|
||||
u16::try_from(n).ok()
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
//! 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, Thread};
|
||||
|
||||
/// 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").
|
||||
let code = match serde_json::to_value(error.code) {
|
||||
Ok(serde_json::Value::String(code)) => code,
|
||||
_ => return format!("Error: <unknown>: {}", error.detail),
|
||||
};
|
||||
format!("Error: {}: {}", code, error.detail)
|
||||
}
|
||||
|
||||
/// 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.
|
||||
if text.trim().is_empty() {
|
||||
return vec![EMPTY_ANSWER.to_string()];
|
||||
}
|
||||
|
||||
let mut parts = Vec::new();
|
||||
let mut rest = text;
|
||||
while rest.chars().count() > MAX_POST {
|
||||
let cut = rest
|
||||
.char_indices()
|
||||
.take(MAX_POST)
|
||||
.filter(|&(i, c)| i != 0 && c == '\n')
|
||||
.last()
|
||||
.map(|(i, _)| i);
|
||||
let (end, skip) = match cut {
|
||||
// A newline past position 0: drop it by cutting before and skipping past it.
|
||||
Some(bi) => (bi, bi + 1),
|
||||
// No newline: cut at the byte index of the MAX_POST-th character, never inside one.
|
||||
None => {
|
||||
let byte = rest
|
||||
.char_indices()
|
||||
.nth(MAX_POST)
|
||||
.map(|(i, _)| i)
|
||||
.unwrap_or(rest.len());
|
||||
(byte, byte)
|
||||
}
|
||||
};
|
||||
parts.push(rest[..end].to_string());
|
||||
rest = &rest[skip..];
|
||||
}
|
||||
parts.push(rest.to_string());
|
||||
parts
|
||||
}
|
||||
|
||||
/// 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>")).
|
||||
let mut stream = match UnixStream::connect(socket) {
|
||||
Ok(stream) => stream,
|
||||
Err(e) => {
|
||||
return Outcome::LoopDown(format!("cannot connect to {}: {}", socket.display(), e));
|
||||
}
|
||||
};
|
||||
|
||||
// 2. `write_frame` one Envelope: v PROTOCOL_VERSION, id 1, final true, msg Turn (else
|
||||
// LoopDown).
|
||||
let turn = Turn {
|
||||
session: batch.session.clone(),
|
||||
content: batch.text.clone(),
|
||||
resume,
|
||||
};
|
||||
if let Err(e) = write_frame(
|
||||
&mut stream,
|
||||
&Envelope {
|
||||
v: PROTOCOL_VERSION,
|
||||
id: 1,
|
||||
r#final: true,
|
||||
msg: Message::Turn(turn),
|
||||
},
|
||||
) {
|
||||
return Outcome::LoopDown(format!("cannot send the turn: {}", e));
|
||||
}
|
||||
|
||||
// 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").
|
||||
loop {
|
||||
let env = match read_frame(&mut stream) {
|
||||
Ok(env) => env,
|
||||
Err(e) => return Outcome::LoopDown(format!("the turn ended early: {}", e)),
|
||||
};
|
||||
if env.id != 1 {
|
||||
return Outcome::LoopDown("an unexpected frame".to_string());
|
||||
}
|
||||
match (env.r#final, &env.msg) {
|
||||
(false, Message::TurnEvent(e)) => on_event(e),
|
||||
(true, Message::TurnDone(d)) => return Outcome::Answer(d.content.clone()),
|
||||
(true, Message::Error(w)) => return Outcome::Refused(w.clone()),
|
||||
_ => return Outcome::LoopDown("an unexpected frame".to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
match one_turn(socket, batch, batch.resume, on_event) {
|
||||
Outcome::Refused(err) if err.code == ErrorCode::NoSuchSession && batch.resume => {
|
||||
one_turn(socket, batch, false, on_event)
|
||||
}
|
||||
outcome => outcome,
|
||||
}
|
||||
}
|
||||
|
||||
/// Post one thing in a thread, logging (via `log`) when Mattermost refuses it, and carry on.
|
||||
fn post(poster: &dyn Poster, thread: &Thread, text: String, log: &dyn Fn(&str)) {
|
||||
if let Err(e) = poster.post(&thread.channel, &thread.root, &text) {
|
||||
log(&format!(
|
||||
"gatewayd: cannot post in {} (thread {}): {}",
|
||||
thread.channel, thread.root, e
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
let outcome = run_turn(socket, batch, &mut |event| {
|
||||
if let TurnEvent::ApprovalPending { approval, .. } = event {
|
||||
post(poster, &batch.thread, approval_text(*approval), log);
|
||||
}
|
||||
});
|
||||
|
||||
match outcome {
|
||||
Outcome::Answer(content) => {
|
||||
for part in split_answer(&content) {
|
||||
post(poster, &batch.thread, part, log);
|
||||
}
|
||||
}
|
||||
Outcome::Refused(err) => post(poster, &batch.thread, error_text(&err), log),
|
||||
Outcome::LoopDown(why) => {
|
||||
log(&format!("gatewayd: {}: {}", batch.session.as_str(), why));
|
||||
post(poster, &batch.thread, LOOP_DOWN.to_string(), log);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
//! 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.
|
||||
self.headers
|
||||
.iter()
|
||||
.find(|(k, _)| k.eq_ignore_ascii_case(name))
|
||||
.map(|(_, v)| v.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
#[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.
|
||||
let mut head = Vec::new();
|
||||
let body_len = body.map(|b| b.len()).unwrap_or(0);
|
||||
head.extend_from_slice(format!("{method} {path} HTTP/1.1\r\n").as_bytes());
|
||||
head.extend_from_slice(format!("Host: {host}\r\n").as_bytes());
|
||||
for (k, v) in headers {
|
||||
head.extend_from_slice(format!("{k}: {v}\r\n").as_bytes());
|
||||
}
|
||||
if body.is_some() {
|
||||
head.extend_from_slice(format!("Content-Length: {body_len}\r\n").as_bytes());
|
||||
}
|
||||
head.extend_from_slice(b"Connection: close\r\n\r\n");
|
||||
stream.write_all(&head)?;
|
||||
if let Some(b) = body {
|
||||
stream.write_all(b)?;
|
||||
}
|
||||
stream.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 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`.
|
||||
write_request(stream, method, host, path, headers, body)?;
|
||||
let head = read_head(stream)?;
|
||||
let body = read_body(stream, &head)?;
|
||||
Ok(Response { head, body })
|
||||
}
|
||||
|
||||
/// 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.
|
||||
let mut raw = Vec::new();
|
||||
let mut byte = [0u8; 1];
|
||||
loop {
|
||||
let n = loop {
|
||||
match stream.read(&mut byte) {
|
||||
Ok(0) => {
|
||||
return Err(HttpError::Protocol(
|
||||
"the head ended before the blank line".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(n) => break n,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
|
||||
Err(e) => return Err(HttpError::Io(e)),
|
||||
}
|
||||
};
|
||||
debug_assert_eq!(n, 1);
|
||||
raw.push(byte[0]);
|
||||
if raw.len() > MAX_HEAD {
|
||||
return Err(HttpError::TooLarge("head"));
|
||||
}
|
||||
if raw.ends_with(b"\r\n\r\n") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let text = std::str::from_utf8(&raw)
|
||||
.map_err(|_| HttpError::Protocol("the head is not UTF-8".to_string()))?;
|
||||
|
||||
let mut lines = text.split("\r\n");
|
||||
let status_line = lines.next().unwrap_or("");
|
||||
let (version, code) = parse_status_line(status_line)?;
|
||||
let _ = version;
|
||||
|
||||
let mut headers = Vec::new();
|
||||
for line in lines {
|
||||
if line.is_empty() {
|
||||
break;
|
||||
}
|
||||
let (name, value) = line
|
||||
.split_once(':')
|
||||
.ok_or_else(|| HttpError::Protocol("a header line has no colon".to_string()))?;
|
||||
if name.is_empty() || name.bytes().any(|b| b == b' ') {
|
||||
return Err(HttpError::Protocol(
|
||||
"a header name is empty or has a space".to_string(),
|
||||
));
|
||||
}
|
||||
headers.push((name.to_string(), value.trim().to_string()));
|
||||
}
|
||||
|
||||
Ok(Head {
|
||||
status: code,
|
||||
headers,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_status_line(line: &str) -> Result<(&str, u16), HttpError> {
|
||||
// "HTTP/1.1 200 OK": a 1.1 or 1.0 version, one space, a 3-digit status in 100..=599, then
|
||||
// optionally one space and a free-text reason. Any other spacing is refused.
|
||||
let mut parts = line.splitn(3, ' ');
|
||||
let version = parts.next().unwrap_or("");
|
||||
let code = parts.next().unwrap_or("");
|
||||
if version != "HTTP/1.1" && version != "HTTP/1.0" {
|
||||
return Err(HttpError::Protocol(
|
||||
"not an HTTP/1.1 status line".to_string(),
|
||||
));
|
||||
}
|
||||
if code.len() != 3 || !code.bytes().all(|b| b.is_ascii_digit()) {
|
||||
return Err(HttpError::Protocol(
|
||||
"the status code is not three digits".to_string(),
|
||||
));
|
||||
}
|
||||
let code: u16 = code
|
||||
.parse()
|
||||
.map_err(|_| HttpError::Protocol("the status code will not parse".to_string()))?;
|
||||
if !(100..=599).contains(&code) {
|
||||
return Err(HttpError::Protocol(
|
||||
"the status code is out of range".to_string(),
|
||||
));
|
||||
}
|
||||
Ok((version, code))
|
||||
}
|
||||
|
||||
/// 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").
|
||||
if head
|
||||
.header("transfer-encoding")
|
||||
.is_some_and(|v| v.eq_ignore_ascii_case("chunked"))
|
||||
{
|
||||
return read_chunked(stream);
|
||||
}
|
||||
match head.header("content-length") {
|
||||
Some(s) => {
|
||||
let len = parse_content_length(s)?;
|
||||
if len > MAX_BODY {
|
||||
return Err(HttpError::TooLarge("body"));
|
||||
}
|
||||
let mut buf = vec![0u8; len];
|
||||
stream.read_exact(&mut buf)?;
|
||||
Ok(buf)
|
||||
}
|
||||
None => {
|
||||
let mut buf = Vec::new();
|
||||
let limit = u64::try_from(MAX_BODY).map_or(u64::MAX, |n| n.saturating_add(1));
|
||||
stream.take(limit).read_to_end(&mut buf)?;
|
||||
if buf.len() > MAX_BODY {
|
||||
return Err(HttpError::TooLarge("body"));
|
||||
}
|
||||
Ok(buf)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_content_length(s: &str) -> Result<usize, HttpError> {
|
||||
// Its own digits only: a non-number is Protocol, an overflowing one is Protocol too.
|
||||
if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) {
|
||||
return Err(HttpError::Protocol(
|
||||
"the Content-Length is not a number".to_string(),
|
||||
));
|
||||
}
|
||||
s.parse::<usize>()
|
||||
.map_err(|_| HttpError::Protocol("the Content-Length is too large".to_string()))
|
||||
}
|
||||
|
||||
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.
|
||||
let mut bytes = Vec::new();
|
||||
let mut byte = [0u8; 1];
|
||||
loop {
|
||||
let n = loop {
|
||||
match stream.read(&mut byte) {
|
||||
Ok(0) => {
|
||||
return Err(HttpError::Protocol(
|
||||
"the line ended before its CRLF".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(n) => break n,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
|
||||
Err(e) => return Err(HttpError::Io(e)),
|
||||
}
|
||||
};
|
||||
debug_assert_eq!(n, 1);
|
||||
match byte[0] {
|
||||
b'\n' => {
|
||||
// Lines end in CRLF; a bare LF is not accepted.
|
||||
if bytes.pop() != Some(b'\r') {
|
||||
return Err(HttpError::Protocol(
|
||||
"a line is not ended by CRLF".to_string(),
|
||||
));
|
||||
}
|
||||
return String::from_utf8(bytes)
|
||||
.map_err(|_| HttpError::Protocol("a line is not UTF-8".to_string()));
|
||||
}
|
||||
other => {
|
||||
bytes.push(other);
|
||||
if bytes.len() > cap {
|
||||
return Err(HttpError::TooLarge("chunk header"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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).
|
||||
let mut out = Vec::new();
|
||||
loop {
|
||||
let size_line = read_line(stream, 1024)?.trim().to_string();
|
||||
let hex = match size_line.split_once(';') {
|
||||
Some((h, _)) => h.trim(),
|
||||
None => &size_line,
|
||||
};
|
||||
let size = parse_hex(hex)?;
|
||||
if size == 0 {
|
||||
loop {
|
||||
let trailer = read_line(stream, 8 * 1024)?;
|
||||
if trailer.is_empty() {
|
||||
return Ok(out);
|
||||
}
|
||||
}
|
||||
}
|
||||
let room = MAX_BODY.saturating_sub(out.len());
|
||||
let size = usize::try_from(size)
|
||||
.ok()
|
||||
.filter(|s| *s <= room)
|
||||
.ok_or(HttpError::TooLarge("body"))?;
|
||||
let mut chunk = vec![0u8; size];
|
||||
stream.read_exact(&mut chunk)?;
|
||||
out.extend_from_slice(&chunk);
|
||||
let terminator = read_line(stream, 2)?;
|
||||
if !terminator.is_empty() {
|
||||
return Err(HttpError::Protocol(
|
||||
"a chunk is not ended by a blank line".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_hex(s: &str) -> Result<u128, HttpError> {
|
||||
// A hex chunk size; an overflow past u128 is refused here, so a size larger than the body cap
|
||||
// still comes back as an error to the caller.
|
||||
let mut value: u128 = 0;
|
||||
for b in s.bytes() {
|
||||
let d = match b {
|
||||
b'0'..=b'9' => u128::from(b - b'0'),
|
||||
b'a'..=b'f' => u128::from(b - b'a' + 10),
|
||||
b'A'..=b'F' => u128::from(b - b'A' + 10),
|
||||
_ => return Err(HttpError::Protocol("a chunk size is not hex".to_string())),
|
||||
};
|
||||
value = value
|
||||
.checked_mul(16)
|
||||
.and_then(|v| v.checked_add(d))
|
||||
.ok_or_else(|| HttpError::Protocol("a chunk size is too large".to_string()))?;
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
/// 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.
|
||||
let reset = match head.header("x-ratelimit-reset") {
|
||||
Some(v) => v,
|
||||
None => return Duration::from_secs(1),
|
||||
};
|
||||
let value: u64 = match reset.parse() {
|
||||
Ok(v) => v,
|
||||
Err(_) => return Duration::from_secs(1),
|
||||
};
|
||||
let secs = if value > 1_000_000_000 {
|
||||
match now.duration_since(UNIX_EPOCH) {
|
||||
Ok(elapsed) => value.saturating_sub(elapsed.as_secs()).max(1),
|
||||
Err(_) => 1,
|
||||
}
|
||||
} else {
|
||||
value.max(1)
|
||||
};
|
||||
Duration::from_secs(secs.min(MAX_RATE_WAIT.as_secs()))
|
||||
}
|
||||
@@ -1 +1,13 @@
|
||||
//! The Mattermost channel. Outbound connections only.
|
||||
//! `gatewayd`: the Mattermost channel. It carries the owner's messages to `loopd` as turns and posts
|
||||
//! the answers back, over outbound connections only. M4a spec: `docs/specs/2026-09-23-m4a-gateway.md`.
|
||||
|
||||
pub mod config;
|
||||
pub mod deliver;
|
||||
pub mod http;
|
||||
pub mod mm;
|
||||
pub mod net;
|
||||
pub mod secrets;
|
||||
pub mod serve;
|
||||
pub mod sessions;
|
||||
pub mod state;
|
||||
pub mod ws;
|
||||
|
||||
@@ -1,4 +1,93 @@
|
||||
fn main() {
|
||||
eprintln!("gatewayd: not implemented until M4");
|
||||
std::process::exit(2);
|
||||
//! `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 its message with `eprintln!` and returns `ExitCode::from(1)`.
|
||||
// 1. `let config = match Config::load(path) { ... }`: an Err(e) prints
|
||||
// "gatewayd: {e}\n{START_FAILED}".
|
||||
// 2. `let source = match config.token_source() { ... }`: an Err(why) prints
|
||||
// "gatewayd: {}: {why}\n{START_FAILED}" with `path.display()`.
|
||||
// 3. `let loaded = match secrets::load(MATTERMOST_TOKEN, &source, &|k| std::env::var_os(k))`:
|
||||
// an Err(e) prints "gatewayd: {e}" (the error carries its own pointer).
|
||||
// Then `if let Some(warning) = &loaded.warning { eprintln!("{warning}"); }`.
|
||||
// 4. The state file's directory:
|
||||
// `let dir = config.state_path().parent().map(Path::to_path_buf).unwrap_or_default();`
|
||||
// `std::fs::DirBuilder::new().recursive(true).mode(0o700).create(&dir)`: an Err(e) prints
|
||||
// "gatewayd: cannot prepare {}: {e}\n{START_FAILED}" with `dir.display()`.
|
||||
// 5. `let log: gatewayd::serve::Log = Arc::new(|line: &str| eprintln!("{line}"));`
|
||||
// `let stop = run(config, loaded.secret, Tuning::default(), log, &AtomicBool::new(false));`
|
||||
// `eprintln!("{stop}");` and `ExitCode::from(1)`.
|
||||
let config = match Config::load(path) {
|
||||
Ok(config) => config,
|
||||
Err(e) => {
|
||||
eprintln!("gatewayd: {e}\n{START_FAILED}");
|
||||
return ExitCode::from(1);
|
||||
}
|
||||
};
|
||||
let source = match config.token_source() {
|
||||
Ok(source) => source,
|
||||
Err(why) => {
|
||||
eprintln!("gatewayd: {}: {why}\n{START_FAILED}", path.display());
|
||||
return ExitCode::from(1);
|
||||
}
|
||||
};
|
||||
let loaded = match secrets::load(MATTERMOST_TOKEN, &source, &|k| std::env::var_os(k)) {
|
||||
Ok(loaded) => loaded,
|
||||
Err(e) => {
|
||||
eprintln!("gatewayd: {e}");
|
||||
return ExitCode::from(1);
|
||||
}
|
||||
};
|
||||
if let Some(warning) = &loaded.warning {
|
||||
eprintln!("{warning}");
|
||||
}
|
||||
let dir = config
|
||||
.state_path()
|
||||
.parent()
|
||||
.map(Path::to_path_buf)
|
||||
.unwrap_or_default();
|
||||
if let Err(e) = std::fs::DirBuilder::new()
|
||||
.recursive(true)
|
||||
.mode(0o700)
|
||||
.create(&dir)
|
||||
{
|
||||
eprintln!(
|
||||
"gatewayd: cannot prepare {}: {e}\n{START_FAILED}",
|
||||
dir.display()
|
||||
);
|
||||
return ExitCode::from(1);
|
||||
}
|
||||
let log: gatewayd::serve::Log = Arc::new(|line: &str| eprintln!("{line}"));
|
||||
let stop = run(
|
||||
config,
|
||||
loaded.secret,
|
||||
Tuning::default(),
|
||||
log,
|
||||
&AtomicBool::new(false),
|
||||
);
|
||||
eprintln!("{stop}");
|
||||
ExitCode::from(1)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
//! 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 {:?}>").
|
||||
if !valid_id(&self.id) || !valid_id(&self.user_id) || !valid_id(&self.channel_id) {
|
||||
return Err(MmError::Json(format!(
|
||||
"a post with an invalid id: {:?}",
|
||||
self.id
|
||||
)));
|
||||
}
|
||||
if !self.root_id.is_empty() && !valid_id(&self.root_id) {
|
||||
return Err(MmError::Json(format!(
|
||||
"a post with an invalid id: {:?}",
|
||||
self.root_id
|
||||
)));
|
||||
}
|
||||
Ok(self)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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()).
|
||||
serde_json::from_slice(bytes).map_err(|e| MmError::Json(e.to_string()))
|
||||
}
|
||||
|
||||
/// 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("")).
|
||||
let raw: RawEvent = json(text.as_bytes())?;
|
||||
match raw.event.as_str() {
|
||||
"hello" => Ok(Event::Hello),
|
||||
"posted" => {
|
||||
let post = raw
|
||||
.data
|
||||
.get("post")
|
||||
.and_then(|p| p.as_str())
|
||||
.ok_or_else(|| {
|
||||
MmError::Json("a posted event carries no post string".to_string())
|
||||
})?;
|
||||
let post: Post = json(post.as_bytes())?;
|
||||
let channel_type = raw
|
||||
.data
|
||||
.get("channel_type")
|
||||
.and_then(|c| c.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
Ok(Event::Posted {
|
||||
post: post.check()?,
|
||||
channel_type,
|
||||
})
|
||||
}
|
||||
other => Ok(Event::Other(other.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
serde_json::json!({
|
||||
"action": "user_typing",
|
||||
"seq": seq,
|
||||
"data": {"channel_id": channel, "parent_id": parent}
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// 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.
|
||||
let list: PostList = json(body)?;
|
||||
let full = list.order.len() >= SINCE_LIMIT;
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut posts = Vec::new();
|
||||
for id in &list.order {
|
||||
let post = match list.posts.get(id) {
|
||||
Some(p) => p.clone(),
|
||||
None => continue,
|
||||
};
|
||||
let post = post.check()?;
|
||||
if post.create_at <= since || post.delete_at != 0 {
|
||||
continue;
|
||||
}
|
||||
if seen.insert(post.id.clone()) {
|
||||
posts.push(post);
|
||||
}
|
||||
}
|
||||
posts.sort_by(|a, b| a.create_at.cmp(&b.create_at).then_with(|| a.id.cmp(&b.id)));
|
||||
Ok(Since { posts, full })
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
//! 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.
|
||||
Client {
|
||||
connector,
|
||||
token,
|
||||
timeout,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn connector(&self) -> &Connector {
|
||||
// The Connector.
|
||||
&self.connector
|
||||
}
|
||||
|
||||
pub fn token(&self) -> &Secret {
|
||||
// The Secret.
|
||||
&self.token
|
||||
}
|
||||
|
||||
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))`.
|
||||
let mut stream = self
|
||||
.connector
|
||||
.connect(self.timeout)
|
||||
.map_err(|e| MmError::Net(format!("{method} {path}: {e}")))?;
|
||||
stream
|
||||
.set_read_timeout(Some(self.timeout))
|
||||
.map_err(|e| MmError::Net(format!("{method} {path}: {e}")))?;
|
||||
// 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())`.
|
||||
let auth = format!("Bearer {}", self.token.expose());
|
||||
let mut headers: Vec<(&str, &str)> = vec![
|
||||
("Authorization", auth.as_str()),
|
||||
("Accept", "application/json"),
|
||||
];
|
||||
if body.is_some() {
|
||||
headers.push(("Content-Type", "application/json"));
|
||||
}
|
||||
// 3. `http::request`. Every error on the way is Net("<method> <path>: <error>").
|
||||
http::request(
|
||||
&mut stream,
|
||||
method,
|
||||
&host_header(self.connector.server()),
|
||||
path,
|
||||
&headers,
|
||||
body,
|
||||
)
|
||||
.map_err(|e| MmError::Net(format!("{method} {path}: {e}")))
|
||||
}
|
||||
|
||||
/// 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).
|
||||
let mut tries: u32 = 0;
|
||||
loop {
|
||||
let answer = self.once(method, path, body)?;
|
||||
let status = answer.head.status;
|
||||
if (200..300).contains(&status) {
|
||||
return Ok(answer.body);
|
||||
}
|
||||
match status {
|
||||
401 | 403 => return Err(MmError::Auth(status)),
|
||||
429 => {
|
||||
let wait = rate_limit_wait(&answer.head, SystemTime::now());
|
||||
if tries < RETRIES {
|
||||
tries += 1;
|
||||
std::thread::sleep(wait);
|
||||
continue;
|
||||
}
|
||||
return Err(MmError::RateLimited(wait));
|
||||
}
|
||||
_ if status >= 500 => {
|
||||
if tries < RETRIES {
|
||||
tries += 1;
|
||||
std::thread::sleep(RETRY_5XX);
|
||||
continue;
|
||||
}
|
||||
return Err(status_error(status, &answer.body));
|
||||
}
|
||||
_ => return Err(status_error(status, &answer.body)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `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.
|
||||
let body = self.call("GET", "/api/v4/users/me", None)?;
|
||||
let me: Me = json(&body)?;
|
||||
if !valid_id(&me.id) || me.username.is_empty() {
|
||||
return Err(MmError::Json(
|
||||
"the user me has no id or no username".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(me)
|
||||
}
|
||||
|
||||
/// `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.
|
||||
let payload = serde_json::json!({
|
||||
"channel_id": channel,
|
||||
"root_id": root,
|
||||
"message": message
|
||||
})
|
||||
.to_string();
|
||||
let body = self.call("POST", "/api/v4/posts", Some(payload.as_bytes()))?;
|
||||
let post: Post = json(&body)?;
|
||||
post.check()
|
||||
}
|
||||
|
||||
/// `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`.
|
||||
if !valid_id(channel) {
|
||||
return Err(MmError::Json(
|
||||
"a channel path must be a Mattermost id".to_string(),
|
||||
));
|
||||
}
|
||||
let body = self.call(
|
||||
"GET",
|
||||
&format!("/api/v4/channels/{channel}/posts?since={since}"),
|
||||
None,
|
||||
)?;
|
||||
since_list(&body, since)
|
||||
}
|
||||
|
||||
/// `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).
|
||||
let payload = serde_json::to_vec(&serde_json::json!([a, b]))
|
||||
.map_err(|e| MmError::Json(e.to_string()))?;
|
||||
let body = self.call("POST", "/api/v4/channels/direct", Some(payload.as_slice()))?;
|
||||
let channel: Channel = json(&body)?;
|
||||
if !valid_id(&channel.id) {
|
||||
return Err(MmError::Json("the direct channel has no id".to_string()));
|
||||
}
|
||||
Ok(channel.id)
|
||||
}
|
||||
}
|
||||
|
||||
fn status_error(status: u16, body: &[u8]) -> MmError {
|
||||
// Any non-2xx, non-4xx-that-isn't-401/403/429/5xx: the status and the first `BODY_KEPT`
|
||||
// characters, lossy UTF-8.
|
||||
MmError::Status(
|
||||
status,
|
||||
String::from_utf8_lossy(body)
|
||||
.chars()
|
||||
.take(BODY_KEPT)
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
//! 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 {
|
||||
match self {
|
||||
Stream::Plain(s) => s,
|
||||
Stream::Tls(s) => s.get_ref(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_read_timeout(&self, timeout: Option<Duration>) -> std::io::Result<()> {
|
||||
self.tcp().set_read_timeout(timeout)
|
||||
}
|
||||
}
|
||||
|
||||
impl Read for Stream {
|
||||
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
|
||||
match self {
|
||||
Stream::Plain(s) => s.read(buf),
|
||||
Stream::Tls(s) => s.read(buf),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Write for Stream {
|
||||
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
|
||||
match self {
|
||||
Stream::Plain(s) => s.write(buf),
|
||||
Stream::Tls(s) => s.write(buf),
|
||||
}
|
||||
}
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
match self {
|
||||
Stream::Plain(s) => s.flush(),
|
||||
Stream::Tls(s) => s.flush(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
let tls = match server.tls {
|
||||
true => Some(Arc::new(client_config(ca_file)?)),
|
||||
false => None,
|
||||
};
|
||||
Ok(Connector { server, tls })
|
||||
}
|
||||
|
||||
pub fn server(&self) -> &ServerUrl {
|
||||
&self.server
|
||||
}
|
||||
|
||||
/// 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,224 @@
|
||||
//! 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::io::Read;
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
use std::path::Path;
|
||||
|
||||
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: `read_checked` checks the path, opens and re-checks
|
||||
// the same file handle, reads 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.
|
||||
match source {
|
||||
SecretSource::Credential(cred) => {
|
||||
let dir = match env("CREDENTIALS_DIRECTORY") {
|
||||
Some(dir) => dir,
|
||||
None => return Err(SecretError {
|
||||
name: name.to_string(),
|
||||
why: "CREDENTIALS_DIRECTORY is not set: gatewayd was not started by systemd with LoadCredentialEncrypted=".to_string(),
|
||||
}),
|
||||
};
|
||||
let path = Path::new(dir.as_os_str()).join(cred);
|
||||
let bytes = match std::fs::read(&path) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(e) => {
|
||||
return Err(SecretError {
|
||||
name: name.to_string(),
|
||||
why: format!("cannot read the credential {}: {}", path.display(), e),
|
||||
});
|
||||
}
|
||||
};
|
||||
let secret = value(bytes).map_err(|why| SecretError {
|
||||
name: name.to_string(),
|
||||
why,
|
||||
})?;
|
||||
Ok(Loaded {
|
||||
secret,
|
||||
warning: None,
|
||||
})
|
||||
}
|
||||
SecretSource::Env(var) => {
|
||||
let val = match env(var) {
|
||||
Some(val) => val,
|
||||
None => {
|
||||
return Err(SecretError {
|
||||
name: name.to_string(),
|
||||
why: format!("the environment variable {} is not set", var),
|
||||
});
|
||||
}
|
||||
};
|
||||
let text = match val.to_str() {
|
||||
Some(text) => text,
|
||||
None => {
|
||||
return Err(SecretError {
|
||||
name: name.to_string(),
|
||||
why: format!("the environment variable {} is not UTF-8", var),
|
||||
});
|
||||
}
|
||||
};
|
||||
let secret = value(text.as_bytes().to_vec()).map_err(|why| SecretError {
|
||||
name: name.to_string(),
|
||||
why,
|
||||
})?;
|
||||
Ok(Loaded {
|
||||
secret,
|
||||
warning: None,
|
||||
})
|
||||
}
|
||||
SecretSource::File(path) => {
|
||||
let mut bytes = match read_checked(path, &|| {}) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(why) => {
|
||||
return Err(SecretError {
|
||||
name: name.to_string(),
|
||||
why,
|
||||
});
|
||||
}
|
||||
};
|
||||
let secret = value(std::mem::take(&mut *bytes)).map_err(|why| SecretError {
|
||||
name: name.to_string(),
|
||||
why,
|
||||
})?;
|
||||
Ok(Loaded {
|
||||
secret,
|
||||
warning: Some(format!(
|
||||
"gatewayd: warning: secret {} is read in plaintext from {}; a systemd credential keeps it encrypted at rest ({RUNBOOK_FILE})",
|
||||
name,
|
||||
path.display()
|
||||
)),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The bytes of an owner-only regular file, read from the very file that was checked. `between`
|
||||
/// runs after the path is checked and before it is opened: `load` passes `&|| {}`; tests use it
|
||||
/// to swap the file.
|
||||
pub fn read_checked(path: &Path, between: &dyn Fn()) -> Result<Zeroizing<Vec<u8>>, String> {
|
||||
// 1. `path.is_absolute()`, else "<path> is not an absolute path".
|
||||
// 2. `let named = std::fs::symlink_metadata(path)`, an Err(e) is "cannot read <path>: <e>";
|
||||
// `named.file_type().is_symlink()` is "<path> is a symbolic link";
|
||||
// `!named.file_type().is_file()` is "<path> is not a regular file".
|
||||
// 3. `between();`
|
||||
// 4. `let mut file = std::fs::File::open(path)`, an Err(e) is "cannot read <path>: <e>";
|
||||
// `let opened = file.metadata()`, the same error text.
|
||||
// 5. `if (opened.dev(), opened.ino()) != (named.dev(), named.ino())`:
|
||||
// "<path> changed while it was read".
|
||||
// 6. The owner and mode checks from the original file check, word for word, on `opened` (not
|
||||
// on `named`): the uid of "/proc/self"; "<path> is not owned by the user gatewayd runs as";
|
||||
// "<path> has mode <mode:03o>; only the owner may read it (0600 or 0400)".
|
||||
// 7. `let mut bytes = Zeroizing::new(Vec::new());` then `file.read_to_end(&mut bytes)`, an
|
||||
// Err(e) is "cannot read <path>: <e>". Ok(bytes).
|
||||
if !path.is_absolute() {
|
||||
return Err(format!("{} is not an absolute path", path.display()));
|
||||
}
|
||||
let named = std::fs::symlink_metadata(path)
|
||||
.map_err(|e| format!("cannot read {}: {}", path.display(), e))?;
|
||||
if named.file_type().is_symlink() {
|
||||
return Err(format!("{} is a symbolic link", path.display()));
|
||||
}
|
||||
if !named.file_type().is_file() {
|
||||
return Err(format!("{} is not a regular file", path.display()));
|
||||
}
|
||||
between();
|
||||
let mut file =
|
||||
std::fs::File::open(path).map_err(|e| format!("cannot read {}: {}", path.display(), e))?;
|
||||
let opened = file
|
||||
.metadata()
|
||||
.map_err(|e| format!("cannot read {}: {}", path.display(), e))?;
|
||||
if (opened.dev(), opened.ino()) != (named.dev(), named.ino()) {
|
||||
return Err(format!("{} changed while it was read", path.display()));
|
||||
}
|
||||
let owner = std::fs::metadata("/proc/self")
|
||||
.map_err(|e| format!("cannot read /proc/self: {}", e))?
|
||||
.uid();
|
||||
if opened.uid() != owner {
|
||||
return Err(format!(
|
||||
"{} is not owned by the user gatewayd runs as",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
let mode = opened.mode() & 0o777;
|
||||
if mode & 0o077 != 0 {
|
||||
return Err(format!(
|
||||
"{} has mode {:03o}; only the owner may read it (0600 or 0400)",
|
||||
path.display(),
|
||||
mode
|
||||
));
|
||||
}
|
||||
let mut bytes = Zeroizing::new(Vec::new());
|
||||
file.read_to_end(&mut bytes)
|
||||
.map_err(|e| format!("cannot read {}: {}", path.display(), e))?;
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
/// 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 the bytes in `Zeroizing`
|
||||
// until they are inside the `Secret`.
|
||||
let bytes = Zeroizing::new(bytes);
|
||||
let text = std::str::from_utf8(&bytes).map_err(|_| "the value is not UTF-8".to_string())?;
|
||||
let text = text.strip_suffix('\n').unwrap_or(text);
|
||||
if text.is_empty() {
|
||||
return Err("the value is empty".to_string());
|
||||
}
|
||||
Ok(Secret::new(text.to_string()))
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
//! 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 {
|
||||
// `let ms = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_millis()).unwrap_or(0);`
|
||||
// then `i64::try_from(ms).unwrap_or(i64::MAX)`.
|
||||
let ms = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_millis())
|
||||
.unwrap_or(0);
|
||||
i64::try_from(ms).unwrap_or(i64::MAX)
|
||||
}
|
||||
|
||||
impl Gateway {
|
||||
/// Post in a thread; a failure is logged, not fatal.
|
||||
pub(super) fn post(&self, channel: &str, root: &str, text: &str) {
|
||||
// `if let Err(e) = self.client.create_post(channel, root, text)`: log
|
||||
// "gatewayd: cannot post in <channel> (thread <root>): <e>", with `(self.log)(&line)`.
|
||||
// Nothing else: posting records nothing in the state.
|
||||
if let Err(e) = self.client.create_post(channel, root, text) {
|
||||
(self.log)(&format!(
|
||||
"gatewayd: cannot post in {channel} (thread {root}): {e}"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// Is this channel one whose posts `gatewayd` keeps track of?
|
||||
fn tracked(&self, channel: &str, channel_type: &str) -> bool {
|
||||
// `channel_type == "D" || self.config.allow.channels.iter().any(|c| c == channel)`.
|
||||
channel_type == "D" || self.config.allow.channels.iter().any(|c| c == channel)
|
||||
}
|
||||
|
||||
/// One new post, live or caught up.
|
||||
pub(super) fn handle_post(&mut self, post: &Post, channel_type: &str) -> Result<(), Stop> {
|
||||
// 1. `if !self.tracked(&post.channel_id, channel_type) || self.state.seen(&post.id)`:
|
||||
// return Ok(()).
|
||||
// 2. `self.state.handled(&post.id, &post.channel_id, post.create_at)?;` before anything
|
||||
// else: after a crash, a post is never answered twice.
|
||||
// 3. Route it, with the state answering "is this thread ours?":
|
||||
// `let state = &self.state;`
|
||||
// `let route = self.router.route(post, channel_type, &|root| state.knows_thread(root));`
|
||||
// then `match route`:
|
||||
// - Route::Ignore(Ignored::NotAllowed): log
|
||||
// "gatewayd: ignored post <post.id> from <post.user_id>: not allowed"
|
||||
// (never the text).
|
||||
// - Route::Ignore(_): nothing.
|
||||
// - Route::Reply { thread, text }: `self.post(&thread.channel, &thread.root, &text)`.
|
||||
// - Route::Queue(message): if `message.joins_thread`,
|
||||
// `self.state.join_thread(&message.thread.root)?`; then
|
||||
// `match self.queues.push(message)`:
|
||||
// Pushed::Start(batch) -> `self.start(batch)?`; Pushed::Waiting -> nothing;
|
||||
// Pushed::Full(thread) -> `self.post(&thread.channel, &thread.root, BUSY)`.
|
||||
// 4. Ok(()).
|
||||
if !self.tracked(&post.channel_id, channel_type) || self.state.seen(&post.id) {
|
||||
return Ok(());
|
||||
}
|
||||
self.state
|
||||
.handled(&post.id, &post.channel_id, post.create_at)?;
|
||||
let state = &self.state;
|
||||
let route = self
|
||||
.router
|
||||
.route(post, channel_type, &|root| state.knows_thread(root));
|
||||
match route {
|
||||
Route::Ignore(Ignored::NotAllowed) => {
|
||||
(self.log)(&format!(
|
||||
"gatewayd: ignored post {} from {}: not allowed",
|
||||
post.id, post.user_id
|
||||
));
|
||||
}
|
||||
Route::Ignore(_) => {}
|
||||
Route::Reply { thread, text } => self.post(&thread.channel, &thread.root, &text),
|
||||
Route::Queue(message) => {
|
||||
if message.joins_thread {
|
||||
self.state.join_thread(&message.thread.root)?;
|
||||
}
|
||||
match self.queues.push(message) {
|
||||
Pushed::Start(batch) => self.start(batch)?,
|
||||
Pushed::Waiting => {}
|
||||
Pushed::Full(thread) => self.post(&thread.channel, &thread.root, BUSY),
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Record the turn as in flight and run it on its own thread.
|
||||
fn start(&mut self, batch: Batch) -> Result<(), Stop> {
|
||||
// 1. `self.state.start_turn(InFlight { session: batch.session.as_str().to_string(),
|
||||
// channel: batch.thread.channel.clone(), root: batch.thread.root.clone() })?;`
|
||||
// 2. Clone what the thread takes, before the `move`:
|
||||
// `let (client, socket, log, done) = (Arc::clone(&self.client),
|
||||
// self.loop_socket.clone(), Arc::clone(&self.log), self.done_tx.clone());`
|
||||
// `let (session, thread) = (batch.session.clone(), batch.thread.clone());`
|
||||
// 3. `let spawned = std::thread::Builder::new().spawn(move || {
|
||||
// deliver(client.as_ref(), &socket, &batch, &|line| log(line));
|
||||
// let _ = done.send(batch.session); });`
|
||||
// 4. `if let Err(e) = spawned`: log "gatewayd: cannot start a thread for <session>: <e>"
|
||||
// (`session.as_str()`), `self.post(&thread.channel, &thread.root, LOOP_DOWN)`, and
|
||||
// `let _ = self.done_tx.send(session);` so the session does not stay busy.
|
||||
// 5. Ok(()).
|
||||
self.state.start_turn(InFlight {
|
||||
session: batch.session.as_str().to_string(),
|
||||
channel: batch.thread.channel.clone(),
|
||||
root: batch.thread.root.clone(),
|
||||
})?;
|
||||
let (client, socket, log, done) = (
|
||||
Arc::clone(&self.client),
|
||||
self.loop_socket.clone(),
|
||||
Arc::clone(&self.log),
|
||||
self.done_tx.clone(),
|
||||
);
|
||||
let (session, thread) = (batch.session.clone(), batch.thread.clone());
|
||||
let spawned = std::thread::Builder::new().spawn(move || {
|
||||
deliver(client.as_ref(), &socket, &batch, &|line| log(line));
|
||||
let _ = done.send(batch.session);
|
||||
});
|
||||
if let Err(e) = spawned {
|
||||
(self.log)(&format!(
|
||||
"gatewayd: cannot start a thread for {}: {e}",
|
||||
session.as_str()
|
||||
));
|
||||
self.post(&thread.channel, &thread.root, LOOP_DOWN);
|
||||
let _ = self.done_tx.send(session);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Turns that ended: out of flight, and the next batch of each session started.
|
||||
pub(super) fn finished(&mut self) -> Result<(), Stop> {
|
||||
// `while let Ok(session) = self.done_rx.try_recv() {` (never blocks)
|
||||
// `self.state.end_turn(session.as_str())?;`
|
||||
// `if let Some(batch) = self.queues.finish(&session) { self.start(batch)?; }` `}`
|
||||
// Then Ok(()).
|
||||
while let Ok(session) = self.done_rx.try_recv() {
|
||||
self.state.end_turn(session.as_str())?;
|
||||
if let Some(batch) = self.queues.finish(&session) {
|
||||
self.start(batch)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Show this bot as typing in every thread with a turn running.
|
||||
pub(super) fn typing(&mut self, ws: &mut Ws) -> Result<(), WsError> {
|
||||
// `for thread in self.queues.threads() { self.seq += 1;
|
||||
// ws.send_text(&typing(self.seq, &thread.channel, &thread.root))?; }` then Ok(()).
|
||||
for thread in self.queues.threads() {
|
||||
self.seq += 1;
|
||||
ws.send_text(&typing(self.seq, &thread.channel, &thread.root))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Answer what arrived while `gatewayd` was away: the direct channel with each allowed user,
|
||||
/// and each allowed channel. Written for you: it is the glue.
|
||||
pub(super) fn catch_up(&mut self) -> Result<(), Stop> {
|
||||
let mut channels = Vec::new();
|
||||
for user in self.config.allow.users.clone() {
|
||||
match self.client.direct_channel(&self.me.id, &user) {
|
||||
Ok(id) => channels.push((id, "D")),
|
||||
Err(e) => (self.log)(&format!("gatewayd: no direct channel with {user}: {e}")),
|
||||
}
|
||||
}
|
||||
channels.extend(self.config.allow.channels.iter().map(|c| (c.clone(), "O")));
|
||||
for (channel, channel_type) in channels {
|
||||
self.catch_up_channel(&channel, channel_type)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// One channel's posts since its mark. A channel seen for the first time starts from now.
|
||||
fn catch_up_channel(&mut self, channel: &str, channel_type: &str) -> Result<(), Stop> {
|
||||
// 1. `let Some(since) = self.state.since(channel) else { ... }`: with no mark,
|
||||
// `self.state.mark(channel, now_ms())?` and return Ok(()).
|
||||
// 2. `self.client.posts_since(channel, since)`: an Err(e) is logged
|
||||
// "gatewayd: cannot catch up <channel>: <e>" and returns Ok(()).
|
||||
// 3. When `found.full`, log "gatewayd: <channel>: too many posts to catch up; some may be
|
||||
// missed".
|
||||
// 4. `for post in &found.posts { self.handle_post(post, channel_type)?; }` and Ok(()).
|
||||
let Some(since) = self.state.since(channel) else {
|
||||
self.state.mark(channel, now_ms())?;
|
||||
return Ok(());
|
||||
};
|
||||
let found = match self.client.posts_since(channel, since) {
|
||||
Ok(found) => found,
|
||||
Err(e) => {
|
||||
(self.log)(&format!("gatewayd: cannot catch up {channel}: {e}"));
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
if found.full {
|
||||
(self.log)(&format!(
|
||||
"gatewayd: {channel}: too many posts to catch up; some may be missed"
|
||||
));
|
||||
}
|
||||
for post in &found.posts {
|
||||
self.handle_post(post, channel_type)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
//! 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)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
// `let last = tuning.backoff.last().copied().unwrap_or(Duration::from_secs(30));` then
|
||||
// `tuning.backoff.get(n).copied().unwrap_or(last)`.
|
||||
let last = tuning
|
||||
.backoff
|
||||
.last()
|
||||
.copied()
|
||||
.unwrap_or(Duration::from_secs(30));
|
||||
tuning.backoff.get(n).copied().unwrap_or(last)
|
||||
}
|
||||
|
||||
/// Sleep for `d`, in short steps, unless `stop` is set.
|
||||
fn sleep_unless(stop: &AtomicBool, d: Duration) {
|
||||
// `let until = Instant::now() + d;` then, while `!stop.load(Ordering::SeqCst)` and
|
||||
// `Instant::now() < until`, sleep
|
||||
// `Duration::from_millis(20).min(until.saturating_duration_since(Instant::now()))`.
|
||||
let until = Instant::now() + d;
|
||||
while !stop.load(Ordering::SeqCst) && Instant::now() < until {
|
||||
std::thread::sleep(
|
||||
Duration::from_millis(20).min(until.saturating_duration_since(Instant::now())),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
let me = client.me()?;
|
||||
let timing = Timing {
|
||||
ping_every: Duration::from_millis(config.limits.ping_every_ms),
|
||||
dead_after: Duration::from_millis(config.limits.dead_after_ms),
|
||||
};
|
||||
let random =
|
||||
File::open("/dev/urandom").map_err(|e| MmError::Net(format!("/dev/urandom: {e}")))?;
|
||||
let net = |e: String| MmError::Net(format!("websocket: {e}"));
|
||||
let mut ws = Ws::open(
|
||||
client.connector(),
|
||||
client.token().expose(),
|
||||
timing,
|
||||
Box::new(random),
|
||||
)
|
||||
.map_err(|e| net(e.to_string()))?;
|
||||
// The server says hello first; anything before it is skipped, and silence is an error.
|
||||
let until = Instant::now() + timing.dead_after;
|
||||
while Instant::now() < until {
|
||||
if let Some(text) = ws.poll(tuning.poll).map_err(|e| net(e.to_string()))?
|
||||
&& matches!(parse_event(&text), Ok(Event::Hello))
|
||||
{
|
||||
return Ok((me, ws));
|
||||
}
|
||||
}
|
||||
Err(net("no hello from the server".to_string()))
|
||||
}
|
||||
|
||||
impl Gateway {
|
||||
fn new(config: Config, client: Arc<Client>, state: State, log: Log) -> Gateway {
|
||||
let (done_tx, done_rx) = mpsc::channel();
|
||||
let limit = usize::try_from(config.limits.queue).unwrap_or(usize::MAX);
|
||||
Gateway {
|
||||
// Both are set on each connection, from `users/me`.
|
||||
router: Router::new("", "", &[], &[]),
|
||||
me: Me {
|
||||
id: String::new(),
|
||||
username: String::new(),
|
||||
},
|
||||
loop_socket: config.loop_socket(),
|
||||
queues: Queues::new(limit),
|
||||
config,
|
||||
client,
|
||||
state,
|
||||
done_tx,
|
||||
done_rx,
|
||||
log,
|
||||
seq: 0,
|
||||
restarted: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
// 1. Log exactly "gatewayd: connected to <url> as <username>"
|
||||
// (`self.config.mattermost.url`, `me.username`), with `(self.log)(&line)`.
|
||||
// 2. `let allow = &self.config.allow;`
|
||||
// `self.router = Router::new(&me.id, &me.username, &allow.users, &allow.channels);`
|
||||
// then `self.me = me;`.
|
||||
// 3. The first time only (`if !self.restarted { self.restarted = true; ... }`): for each
|
||||
// `turn` in `self.state.take_in_flight()?`, `self.post(&turn.channel, &turn.root,
|
||||
// INTERRUPTED)`. A later reconnect must not: those turns are still running.
|
||||
// 4. Ok(()).
|
||||
(self.log)(&format!(
|
||||
"gatewayd: connected to {} as {}",
|
||||
self.config.mattermost.url, me.username
|
||||
));
|
||||
let allow = &self.config.allow;
|
||||
self.router = Router::new(&me.id, &me.username, &allow.users, &allow.channels);
|
||||
self.me = me;
|
||||
if !self.restarted {
|
||||
self.restarted = true;
|
||||
for turn in self.state.take_in_flight()? {
|
||||
self.post(&turn.channel, &turn.root, INTERRUPTED);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 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,294 @@
|
||||
//! 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.
|
||||
let mut names = Vec::new();
|
||||
let bytes = message.as_bytes();
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
if bytes[i] != b'@' {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let start = i + 1;
|
||||
let mut j = start;
|
||||
while j < bytes.len() {
|
||||
let c = bytes[j];
|
||||
if c.is_ascii_alphanumeric() || c == b'.' || c == b'-' || c == b'_' {
|
||||
j += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if j > start {
|
||||
let trimmed = message[start..j].trim_end_matches('.').to_lowercase();
|
||||
if !trimmed.is_empty() {
|
||||
names.push(trimmed);
|
||||
}
|
||||
}
|
||||
i = j;
|
||||
}
|
||||
names
|
||||
}
|
||||
|
||||
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.
|
||||
Router {
|
||||
me_id: me_id.to_string(),
|
||||
me_name: me_name.to_lowercase(),
|
||||
users: users.iter().cloned().collect(),
|
||||
channels: channels.iter().cloned().collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
let names = named(&post.message);
|
||||
if names.iter().any(|n| n == &self.me_name) {
|
||||
return true;
|
||||
}
|
||||
if post.root_id.is_empty() || !known(&post.root_id) {
|
||||
return false;
|
||||
}
|
||||
names.iter().all(|n| EVERYONE.contains(&n.as_str()))
|
||||
}
|
||||
|
||||
/// 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".
|
||||
if post.user_id == self.me_id {
|
||||
return Route::Ignore(Ignored::Own);
|
||||
}
|
||||
if !post.kind.is_empty() {
|
||||
return Route::Ignore(Ignored::System);
|
||||
}
|
||||
if !self.users.contains(&post.user_id) {
|
||||
return Route::Ignore(Ignored::NotAllowed);
|
||||
}
|
||||
let channel_ok = match channel_type {
|
||||
"D" => true,
|
||||
"O" | "P" | "G" => self.channels.contains(&post.channel_id) && self.for_us(post, known),
|
||||
_ => false,
|
||||
};
|
||||
if !channel_ok {
|
||||
return Route::Ignore(Ignored::NotForUs);
|
||||
}
|
||||
|
||||
let root = if post.root_id.is_empty() {
|
||||
post.id.clone()
|
||||
} else {
|
||||
post.root_id.clone()
|
||||
};
|
||||
let session = match SessionId::new(&format!("mm-{root}")) {
|
||||
Ok(session) => session,
|
||||
Err(_) => return Route::Ignore(Ignored::NotForUs),
|
||||
};
|
||||
let thread = Thread {
|
||||
channel: post.channel_id.clone(),
|
||||
root: root.clone(),
|
||||
};
|
||||
|
||||
let message = &post.message;
|
||||
if let Some(rest) = message.strip_prefix("!!") {
|
||||
return Route::Queue(Message {
|
||||
session,
|
||||
thread,
|
||||
resume: !post.root_id.is_empty(),
|
||||
text: format!("!{rest}"),
|
||||
joins_thread: channel_type != "D",
|
||||
});
|
||||
}
|
||||
if let Some(after) = message.strip_prefix('!') {
|
||||
let answer = match after.split_whitespace().next() {
|
||||
Some("approve") | Some("deny") => M4B_COMMAND,
|
||||
_ => UNKNOWN_COMMAND,
|
||||
};
|
||||
return Route::Reply {
|
||||
thread,
|
||||
text: answer.to_string(),
|
||||
};
|
||||
}
|
||||
|
||||
Route::Queue(Message {
|
||||
session,
|
||||
thread,
|
||||
resume: !post.root_id.is_empty(),
|
||||
text: message.clone(),
|
||||
joins_thread: channel_type != "D",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
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.
|
||||
Queues {
|
||||
limit,
|
||||
running: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
let limit = self.limit;
|
||||
if let Some(pending) = self.running.get_mut(&message.session) {
|
||||
if pending.waiting.len() >= limit {
|
||||
return Pushed::Full(pending.thread.clone());
|
||||
}
|
||||
pending.waiting.push(message.text.clone());
|
||||
return Pushed::Waiting;
|
||||
}
|
||||
let batch = Batch {
|
||||
session: message.session.clone(),
|
||||
thread: message.thread.clone(),
|
||||
resume: message.resume,
|
||||
text: message.text.clone(),
|
||||
};
|
||||
self.running.insert(
|
||||
message.session,
|
||||
Pending {
|
||||
thread: message.thread.clone(),
|
||||
waiting: Vec::new(),
|
||||
},
|
||||
);
|
||||
Pushed::Start(batch)
|
||||
}
|
||||
|
||||
/// 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).
|
||||
let mut pending = self.running.remove(session)?;
|
||||
if pending.waiting.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let batch = Batch {
|
||||
session: session.clone(),
|
||||
thread: pending.thread.clone(),
|
||||
resume: true,
|
||||
text: pending.waiting.join("\n\n"),
|
||||
};
|
||||
pending.waiting.clear();
|
||||
self.running.insert(session.clone(), pending);
|
||||
Some(batch)
|
||||
}
|
||||
|
||||
/// How many sessions have a turn running.
|
||||
pub fn running(&self) -> usize {
|
||||
// How many sessions are running.
|
||||
self.running.len()
|
||||
}
|
||||
|
||||
/// 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.
|
||||
self.running.values().map(|p| p.thread.clone()).collect()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
//! `<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.
|
||||
for channel in self.channels.keys() {
|
||||
if !valid_id(channel) {
|
||||
return Some(format!("not a Mattermost id: {channel:?}"));
|
||||
}
|
||||
}
|
||||
for post in &self.recent {
|
||||
if !valid_id(post) {
|
||||
return Some(format!("not a Mattermost id: {post:?}"));
|
||||
}
|
||||
}
|
||||
for root in &self.threads {
|
||||
if !valid_id(root) {
|
||||
return Some(format!("not a Mattermost id: {root:?}"));
|
||||
}
|
||||
}
|
||||
for turn in &self.in_flight {
|
||||
let ok = match turn.session.strip_prefix("mm-") {
|
||||
Some(rest) => valid_id(rest) && valid_id(&turn.channel) && valid_id(&turn.root),
|
||||
None => false,
|
||||
};
|
||||
if !ok {
|
||||
return Some(format!("a turn in flight is not valid: {:?}", turn.session));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
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).
|
||||
let text = match fs::read_to_string(path) {
|
||||
Ok(text) => text,
|
||||
Err(err) if err.kind() == io::ErrorKind::NotFound => {
|
||||
return Ok(State {
|
||||
path: path.to_path_buf(),
|
||||
file: StateFile::default(),
|
||||
});
|
||||
}
|
||||
Err(err) => return Err(StateError::Read(path.to_path_buf(), err.to_string())),
|
||||
};
|
||||
let file = match serde_json::from_str::<StateFile>(&text) {
|
||||
Ok(file) => file,
|
||||
Err(err) => return Err(StateError::Read(path.to_path_buf(), err.to_string())),
|
||||
};
|
||||
if let Some(why) = file.problem() {
|
||||
return Err(StateError::Read(path.to_path_buf(), why));
|
||||
}
|
||||
Ok(State {
|
||||
path: path.to_path_buf(),
|
||||
file,
|
||||
})
|
||||
}
|
||||
|
||||
/// 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).
|
||||
self.persist()
|
||||
.map_err(|err| StateError::Write(self.path.clone(), err))
|
||||
}
|
||||
|
||||
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.
|
||||
let dir = self
|
||||
.path
|
||||
.parent()
|
||||
.ok_or_else(|| io::Error::other("state file has no parent"))?;
|
||||
fs::DirBuilder::new()
|
||||
.recursive(true)
|
||||
.mode(0o700)
|
||||
.create(dir)?;
|
||||
let json = serde_json::to_string(&self.file).map_err(io::Error::other)?;
|
||||
let bytes = format!("{json}\n");
|
||||
let tmp = self.path.with_extension("json.tmp");
|
||||
let mut file = fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.mode(0o600)
|
||||
.open(&tmp)?;
|
||||
file.write_all(bytes.as_bytes())?;
|
||||
file.sync_all()?;
|
||||
fs::rename(&tmp, &self.path)?;
|
||||
fs::File::open(dir)?.sync_all()
|
||||
}
|
||||
|
||||
/// Was this post handled already?
|
||||
pub fn seen(&self, post_id: &str) -> bool {
|
||||
// Is the id in recent?
|
||||
self.file.recent.contains(&post_id.to_string())
|
||||
}
|
||||
|
||||
/// 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.
|
||||
if !self.file.recent.contains(&post_id.to_string()) {
|
||||
self.file.recent.push(post_id.to_string());
|
||||
while self.file.recent.len() > RECENT_KEPT {
|
||||
self.file.recent.remove(0);
|
||||
}
|
||||
}
|
||||
let entry = self
|
||||
.file
|
||||
.channels
|
||||
.entry(channel.to_string())
|
||||
.or_insert(create_at);
|
||||
*entry = (*entry).max(create_at);
|
||||
self.save()
|
||||
}
|
||||
|
||||
/// 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.
|
||||
self.file.channels.get(channel).copied()
|
||||
}
|
||||
|
||||
/// The channels with a mark, for catching up.
|
||||
pub fn channels(&self) -> Vec<String> {
|
||||
// The channel ids that have a mark.
|
||||
self.file.channels.keys().cloned().collect()
|
||||
}
|
||||
|
||||
/// 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.
|
||||
if self.file.channels.contains_key(channel) {
|
||||
return Ok(());
|
||||
}
|
||||
self.file.channels.insert(channel.to_string(), at);
|
||||
self.save()
|
||||
}
|
||||
|
||||
pub fn knows_thread(&self, root: &str) -> bool {
|
||||
// Is the root in threads?
|
||||
self.file.threads.contains(&root.to_string())
|
||||
}
|
||||
|
||||
/// 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.
|
||||
if self.file.threads.contains(&root.to_string()) {
|
||||
return Ok(());
|
||||
}
|
||||
self.file.threads.push(root.to_string());
|
||||
while self.file.threads.len() > THREADS_KEPT {
|
||||
self.file.threads.remove(0);
|
||||
}
|
||||
self.save()
|
||||
}
|
||||
|
||||
pub fn start_turn(&mut self, turn: InFlight) -> Result<(), StateError> {
|
||||
// Remove any entry of the same session, push this one, save.
|
||||
self.file.in_flight.retain(|t| t.session != turn.session);
|
||||
self.file.in_flight.push(turn);
|
||||
self.save()
|
||||
}
|
||||
|
||||
pub fn end_turn(&mut self, session: &str) -> Result<(), StateError> {
|
||||
// Remove the entries of the session, save.
|
||||
self.file.in_flight.retain(|t| t.session != session);
|
||||
self.save()
|
||||
}
|
||||
|
||||
/// 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.
|
||||
let taken = std::mem::take(&mut self.file.in_flight);
|
||||
if taken.is_empty() {
|
||||
return Ok(taken);
|
||||
}
|
||||
self.save().map(|()| taken)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
//! 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.
|
||||
let mut stream = connector
|
||||
.connect(timing.dead_after)
|
||||
.map_err(|e| WsError::Handshake(e.to_string()))?;
|
||||
handshake(
|
||||
&mut stream,
|
||||
&host_header(connector.server()),
|
||||
PATH,
|
||||
token,
|
||||
&mut random,
|
||||
)?;
|
||||
let now = Instant::now();
|
||||
Ok(Ws {
|
||||
stream,
|
||||
decoder: Decoder::new(),
|
||||
random,
|
||||
timing,
|
||||
last_heard: now,
|
||||
last_ping: now,
|
||||
})
|
||||
}
|
||||
|
||||
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.
|
||||
let mut mask = [0u8; 4];
|
||||
self.random.read_exact(&mut mask)?;
|
||||
let frame = encode(opcode, payload, mask);
|
||||
self.stream.write_all(&frame)?;
|
||||
self.stream.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn send_text(&mut self, text: &str) -> Result<(), WsError> {
|
||||
// `send` with TEXT.
|
||||
self.send(TEXT, text.as_bytes())
|
||||
}
|
||||
|
||||
/// 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`. Written for you: it is the glue.
|
||||
pub fn poll(&mut self, wait: Duration) -> Result<Option<String>, WsError> {
|
||||
let until = Instant::now() + wait;
|
||||
loop {
|
||||
if let Some(text) = self.take_messages()? {
|
||||
return Ok(Some(text));
|
||||
}
|
||||
let now = Instant::now();
|
||||
self.keep_alive(now)?;
|
||||
if now >= until {
|
||||
return Ok(None);
|
||||
}
|
||||
let timeout = self.read_timeout(until, now);
|
||||
self.read_some(timeout)?;
|
||||
}
|
||||
}
|
||||
|
||||
/// Act on every whole message the decoder holds, until one is text.
|
||||
fn take_messages(&mut self) -> Result<Option<String>, WsError> {
|
||||
// `while let Some(message) = self.decoder.next_message()? { match message { ... } }`:
|
||||
// - Text(text) -> return Ok(Some(text)).
|
||||
// - Ping(payload) -> `self.send(PONG, &payload)?`.
|
||||
// - Pong(_) -> nothing.
|
||||
// - Close(code, _) -> the reply is the code as 2 big-endian bytes, or empty:
|
||||
// `code.map(|c| c.to_be_bytes().to_vec()).unwrap_or_default()`. Then
|
||||
// `let _ = self.send(CLOSE, &reply);` (the peer may be gone) and Err(WsError::Closed).
|
||||
// After the loop: Ok(None).
|
||||
while let Some(message) = self.decoder.next_message()? {
|
||||
match message {
|
||||
Incoming::Text(text) => return Ok(Some(text)),
|
||||
Incoming::Ping(payload) => self.send(PONG, &payload)?,
|
||||
Incoming::Pong(_) => {}
|
||||
Incoming::Close(code, _) => {
|
||||
let reply = code.map(|c| c.to_be_bytes().to_vec()).unwrap_or_default();
|
||||
let _ = self.send(CLOSE, &reply);
|
||||
return Err(WsError::Closed);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// The dead-peer check, and our ping when one is due.
|
||||
fn keep_alive(&mut self, now: Instant) -> Result<(), WsError> {
|
||||
// 1. `now.duration_since(self.last_heard) >= self.timing.dead_after` -> Err(WsError::Dead).
|
||||
// 2. `now.duration_since(self.last_ping) >= self.timing.ping_every` ->
|
||||
// `self.send(PING, b"")?` and `self.last_ping = now`.
|
||||
// 3. Ok(()).
|
||||
if now.duration_since(self.last_heard) >= self.timing.dead_after {
|
||||
return Err(WsError::Dead);
|
||||
}
|
||||
if now.duration_since(self.last_ping) >= self.timing.ping_every {
|
||||
self.send(PING, b"")?;
|
||||
self.last_ping = now;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// How long the next read may wait: until the soonest of the end of `wait`, the next ping and
|
||||
/// the dead-after limit, and never less than 1 ms.
|
||||
fn read_timeout(&self, until: Instant, now: Instant) -> Duration {
|
||||
// `(self.last_ping + self.timing.ping_every).saturating_duration_since(now)` is the time to
|
||||
// the next ping; the same with last_heard and dead_after; and
|
||||
// `until.saturating_duration_since(now)`. The least of the three (`.min`), then
|
||||
// `.max(Duration::from_millis(1))`.
|
||||
let next_ping = (self.last_ping + self.timing.ping_every).saturating_duration_since(now);
|
||||
let next_dead = (self.last_heard + self.timing.dead_after).saturating_duration_since(now);
|
||||
let until_left = until.saturating_duration_since(now);
|
||||
next_ping
|
||||
.min(next_dead)
|
||||
.min(until_left)
|
||||
.max(Duration::from_millis(1))
|
||||
}
|
||||
|
||||
/// One read, at most `timeout` long, fed to the decoder.
|
||||
fn read_some(&mut self, timeout: Duration) -> Result<(), WsError> {
|
||||
// 1. `self.stream.set_read_timeout(Some(timeout))?`.
|
||||
// 2. `let mut buf = [0u8; 16 * 1024];` and `match self.stream.read(&mut buf)`:
|
||||
// - Ok(0) -> Err(WsError::Closed).
|
||||
// - Ok(n) -> `self.decoder.feed(buf.get(..n).unwrap_or_default())`,
|
||||
// `self.last_heard = Instant::now()`, Ok(()).
|
||||
// - Err(e) whose `e.kind()` is WouldBlock, TimedOut or Interrupted -> Ok(()): nothing
|
||||
// came, and `poll` goes round.
|
||||
// - any other Err(e) -> Err(WsError::Io(e)).
|
||||
self.stream.set_read_timeout(Some(timeout))?;
|
||||
let mut buf = [0u8; 16 * 1024];
|
||||
match self.stream.read(&mut buf) {
|
||||
Ok(0) => Err(WsError::Closed),
|
||||
Ok(n) => {
|
||||
self.decoder.feed(buf.get(..n).unwrap_or_default());
|
||||
self.last_heard = Instant::now();
|
||||
Ok(())
|
||||
}
|
||||
Err(e)
|
||||
if matches!(
|
||||
e.kind(),
|
||||
std::io::ErrorKind::WouldBlock
|
||||
| std::io::ErrorKind::TimedOut
|
||||
| std::io::ErrorKind::Interrupted
|
||||
) =>
|
||||
{
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(WsError::Io(e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
let _ = self.send(CLOSE, &1000u16.to_be_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
/// 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".
|
||||
let default = if server.tls { 443 } else { 80 };
|
||||
if server.port == default {
|
||||
server.host.clone()
|
||||
} else {
|
||||
format!("{}:{}", server.host, server.port)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
//! 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. Written for you: it is the glue.
|
||||
pub fn next_message(&mut self) -> Result<Option<Incoming>, WsError> {
|
||||
loop {
|
||||
let Some(header) = self.header()? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let total = header.header_len.saturating_add(header.payload_len);
|
||||
if self.buf.len() < total {
|
||||
return Ok(None);
|
||||
}
|
||||
let payload: Vec<u8> = self
|
||||
.buf
|
||||
.get(header.header_len..total)
|
||||
.unwrap_or_default()
|
||||
.to_vec();
|
||||
self.buf.drain(..total);
|
||||
match header.opcode {
|
||||
PING => return Ok(Some(Incoming::Ping(payload))),
|
||||
PONG => return Ok(Some(Incoming::Pong(payload))),
|
||||
CLOSE => return close(&payload).map(Some),
|
||||
_ => {
|
||||
// A text or continuation frame: a whole message, or wait for the next frame.
|
||||
if let Some(text) = self.data_frame(header.opcode, header.fin, payload)? {
|
||||
return Ok(Some(Incoming::Text(text)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The next frame's header, once all of it is in `buf`, checked against every rule that does
|
||||
/// not need the payload. It only reads `buf`. Written for you: it is the glue.
|
||||
fn header(&self) -> Result<Option<Header>, WsError> {
|
||||
let (Some(&b0), Some(&b1)) = (self.buf.first(), self.buf.get(1)) else {
|
||||
return Ok(None);
|
||||
};
|
||||
check_first_bytes(b0, b1)?;
|
||||
let fin = b0 & 0x80 != 0;
|
||||
let opcode = b0 & 0x0F;
|
||||
let Some((header_len, payload_len)) = self.length(b1 & 0x7F)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
if opcode & 0x8 != 0 {
|
||||
check_control(fin, payload_len)?;
|
||||
} else {
|
||||
self.check_data(opcode, payload_len)?;
|
||||
}
|
||||
Ok(Some(Header {
|
||||
fin,
|
||||
opcode,
|
||||
header_len,
|
||||
payload_len,
|
||||
}))
|
||||
}
|
||||
|
||||
/// The header's length and the payload's, from the 7-bit length `short` in byte 1 and the
|
||||
/// bytes after it. `Ok(None)` while those bytes have not all arrived.
|
||||
fn length(&self, short: u8) -> Result<Option<(usize, usize)>, WsError> {
|
||||
match short {
|
||||
0..=125 => Ok(Some((2, usize::from(short)))),
|
||||
126 => {
|
||||
let Some(bytes) = self.buf.get(2..4) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let len = u16::from_be_bytes([bytes[0], bytes[1]]);
|
||||
if len < 126 {
|
||||
return Err(WsError::Protocol(
|
||||
"a length not in its shortest form".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(Some((4, usize::from(len))))
|
||||
}
|
||||
127 => {
|
||||
let Some(bytes) = self.buf.get(2..10) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let mut b = [0u8; 8];
|
||||
b.copy_from_slice(bytes);
|
||||
let len = u64::from_be_bytes(b);
|
||||
if len >> 63 != 0 {
|
||||
return Err(WsError::Protocol(
|
||||
"a 64-bit length with its top bit set".to_string(),
|
||||
));
|
||||
}
|
||||
if len <= 0xFFFF {
|
||||
return Err(WsError::Protocol(
|
||||
"a length not in its shortest form".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(Some((10, usize::try_from(len).unwrap_or(usize::MAX))))
|
||||
}
|
||||
_ => Err(WsError::Protocol(
|
||||
"a length byte that is neither 126 nor 127".to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The rules for a text or continuation frame that need what came before.
|
||||
fn check_data(&self, opcode: u8, payload_len: usize) -> Result<(), WsError> {
|
||||
if opcode == TEXT && self.partial.is_some() {
|
||||
return Err(WsError::Protocol(
|
||||
"a new message inside an unfinished one".to_string(),
|
||||
));
|
||||
}
|
||||
if opcode == CONTINUATION && self.partial.is_none() {
|
||||
return Err(WsError::Protocol(
|
||||
"a continuation with nothing to continue".to_string(),
|
||||
));
|
||||
}
|
||||
let so_far = self.partial.as_ref().map_or(0, Vec::len);
|
||||
if payload_len > MAX_MESSAGE.saturating_sub(so_far) {
|
||||
return Err(WsError::TooLarge);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A text or continuation frame's payload, taken out of `buf`: the whole message when `fin`
|
||||
/// is set, or `None` when more frames must come.
|
||||
fn data_frame(
|
||||
&mut self,
|
||||
opcode: u8,
|
||||
fin: bool,
|
||||
payload: Vec<u8>,
|
||||
) -> Result<Option<String>, WsError> {
|
||||
let mut message = if opcode == TEXT {
|
||||
Vec::new()
|
||||
} else {
|
||||
self.partial.take().unwrap_or_default()
|
||||
};
|
||||
message.extend_from_slice(&payload);
|
||||
if !fin {
|
||||
self.partial = Some(message);
|
||||
return Ok(None);
|
||||
}
|
||||
match String::from_utf8(message) {
|
||||
Ok(text) => Ok(Some(text)),
|
||||
Err(_) => Err(WsError::Protocol("text is not UTF-8".to_string())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The rules on the first two bytes alone.
|
||||
fn check_first_bytes(b0: u8, b1: u8) -> Result<(), WsError> {
|
||||
if b0 & 0x70 != 0 {
|
||||
return Err(WsError::Protocol("a reserved bit is set".to_string()));
|
||||
}
|
||||
if b1 & 0x80 != 0 {
|
||||
return Err(WsError::Protocol(
|
||||
"a frame from the server is masked".to_string(),
|
||||
));
|
||||
}
|
||||
if !matches!(b0 & 0x0F, CONTINUATION | TEXT | CLOSE | PING | PONG) {
|
||||
return Err(WsError::Protocol(format!("opcode {}", b0 & 0x0F)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The rules for a control frame (close, ping, pong).
|
||||
fn check_control(fin: bool, payload_len: usize) -> Result<(), WsError> {
|
||||
if !fin {
|
||||
return Err(WsError::Protocol("a fragmented control frame".to_string()));
|
||||
}
|
||||
if payload_len > 125 {
|
||||
return Err(WsError::Protocol(
|
||||
"a control frame over 125 bytes".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A close frame's payload.
|
||||
fn close(payload: &[u8]) -> Result<Incoming, WsError> {
|
||||
match payload {
|
||||
[] => Ok(Incoming::Close(None, String::new())),
|
||||
[_] => Err(WsError::Protocol("a close frame of one byte".to_string())),
|
||||
[a, b, reason @ ..] => {
|
||||
let code = u16::from_be_bytes([*a, *b]);
|
||||
match String::from_utf8(reason.to_vec()) {
|
||||
Ok(reason) => Ok(Incoming::Close(Some(code), reason)),
|
||||
Err(_) => Err(WsError::Protocol(
|
||||
"a close reason that is not UTF-8".to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One whole frame from us: FIN set, masked with `mask`.
|
||||
pub fn encode(opcode: u8, payload: &[u8], mask: [u8; 4]) -> Vec<u8> {
|
||||
let mut out = vec![0x80 | (opcode & 0x0F)];
|
||||
let len = payload.len();
|
||||
if len < 126 {
|
||||
out.push(0x80 | u8::try_from(len).unwrap_or(0));
|
||||
} else if let Ok(len16) = u16::try_from(len) {
|
||||
out.push(0x80 | 126);
|
||||
out.extend_from_slice(&len16.to_be_bytes());
|
||||
} else {
|
||||
out.push(0x80 | 127);
|
||||
out.extend_from_slice(&u64::try_from(len).unwrap_or(u64::MAX).to_be_bytes());
|
||||
}
|
||||
out.extend_from_slice(&mask);
|
||||
out.extend(payload.iter().zip(mask.iter().cycle()).map(|(b, m)| b ^ m));
|
||||
out
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
//! 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.
|
||||
let sextet = |n: u32, shift: u32| -> char {
|
||||
let index = usize::try_from((n >> shift) & 0x3f).unwrap_or(0);
|
||||
char::from(ALPHABET.get(index).copied().unwrap_or(b'A'))
|
||||
};
|
||||
let mut out = String::new();
|
||||
for chunk in bytes.chunks(3) {
|
||||
let b0 = chunk.first().copied().unwrap_or(0);
|
||||
let b1 = chunk.get(1).copied().unwrap_or(0);
|
||||
let b2 = chunk.get(2).copied().unwrap_or(0);
|
||||
let n = (u32::from(b0) << 16) | (u32::from(b1) << 8) | u32::from(b2);
|
||||
out.push(sextet(n, 18));
|
||||
out.push(sextet(n, 12));
|
||||
out.push(if chunk.len() > 1 { sextet(n, 6) } else { '=' });
|
||||
out.push(if chunk.len() > 2 { sextet(n, 0) } else { '=' });
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// 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`.
|
||||
let mut data = Vec::with_capacity(key.len() + GUID.len());
|
||||
data.extend_from_slice(key.as_bytes());
|
||||
data.extend_from_slice(GUID.as_bytes());
|
||||
base64(&proto::sha1::sha1(&data))
|
||||
}
|
||||
|
||||
/// 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.
|
||||
let mut key = [0u8; 16];
|
||||
random.read_exact(&mut key)?;
|
||||
Ok(base64(&key))
|
||||
}
|
||||
|
||||
/// 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)`.
|
||||
if head.status != 101 {
|
||||
return Err(WsError::Handshake(format!("status {}", head.status)));
|
||||
}
|
||||
if !head
|
||||
.header("Upgrade")
|
||||
.is_some_and(|v| v.eq_ignore_ascii_case("websocket"))
|
||||
{
|
||||
return Err(WsError::Handshake(
|
||||
"the Upgrade header is not websocket".to_string(),
|
||||
));
|
||||
}
|
||||
let upgraded = head.header("Connection").is_some_and(|v| {
|
||||
v.split(',')
|
||||
.any(|tok| tok.trim().eq_ignore_ascii_case("upgrade"))
|
||||
});
|
||||
if !upgraded {
|
||||
return Err(WsError::Handshake(
|
||||
"the Connection header has no upgrade token".to_string(),
|
||||
));
|
||||
}
|
||||
match head.header("Sec-WebSocket-Accept") {
|
||||
Some(got) if got == accept_for(key) => Ok(()),
|
||||
_ => Err(WsError::Handshake(
|
||||
"the Sec-WebSocket-Accept is missing or wrong".to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
let key = new_key(random)?;
|
||||
stream.write_all(request_text(host, path, &key, token).as_bytes())?;
|
||||
stream.flush()?;
|
||||
let head = read_head(stream).map_err(|e| WsError::Handshake(e.to_string()))?;
|
||||
check_response(&head, &key)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
//! The WebSocket client (RFC 6455; M4a spec, section 6): the handshake, the frame codec, and the
|
||||
//! connection that uses them.
|
||||
|
||||
pub mod conn;
|
||||
pub mod frame;
|
||||
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)]
|
||||
);
|
||||
}
|
||||
+12
@@ -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.
|
||||
+12
@@ -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-----
|
||||
+13
@@ -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-----
|
||||
+13
@@ -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,57 @@
|
||||
//! The HTTP reader is strict where it can be: a status line with anything but single spaces, and a
|
||||
//! chunk line not ended by CRLF, are protocol errors (M4a review, finding 3).
|
||||
|
||||
use std::io::Cursor;
|
||||
|
||||
use gatewayd::http::{HttpError, read_body, read_head};
|
||||
|
||||
fn read(response: &[u8]) -> Result<Vec<u8>, HttpError> {
|
||||
let mut c = Cursor::new(response.to_vec());
|
||||
let head = read_head(&mut c)?;
|
||||
read_body(&mut c, &head)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_status_line_has_single_spaces() {
|
||||
for line in [
|
||||
"HTTP/1.1 200 OK",
|
||||
"HTTP/1.1 200 OK",
|
||||
" HTTP/1.1 200 OK",
|
||||
"HTTP/1.1\t200 OK",
|
||||
] {
|
||||
let response = format!("{line}\r\nContent-Length: 0\r\n\r\n");
|
||||
let got = read(response.as_bytes());
|
||||
if line == "HTTP/1.1 200 OK" {
|
||||
// The reason phrase is free text: two spaces inside it are allowed.
|
||||
assert!(got.is_ok(), "{line:?}: {got:?}");
|
||||
} else {
|
||||
assert!(
|
||||
matches!(got, Err(HttpError::Protocol(_))),
|
||||
"{line:?}: {got:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
read(b"HTTP/1.1 204\r\n\r\n").is_ok(),
|
||||
"a status line without a reason phrase"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chunk_lines_end_in_crlf() {
|
||||
let head = "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n";
|
||||
let good = format!("{head}2\r\nab\r\n0\r\n\r\n");
|
||||
assert_eq!(read(good.as_bytes()).unwrap(), b"ab");
|
||||
for body in [
|
||||
"2\nab\r\n0\r\n\r\n",
|
||||
"2\r\nab\n0\r\n\r\n",
|
||||
"2\r\nab\r\n0\n\r\n",
|
||||
"2\r\nab\r\n0\r\n\n",
|
||||
] {
|
||||
let got = read(format!("{head}{body}").as_bytes());
|
||||
assert!(
|
||||
matches!(got, Err(HttpError::Protocol(_))),
|
||||
"{body:?}: {got:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,91 @@
|
||||
//! A secret file is read from the file that was checked, never from whatever the path names a
|
||||
//! moment later (M4a review, finding 2). `read_checked` runs `between` after checking the path and
|
||||
//! before opening it; each test swaps something there. Do not edit.
|
||||
|
||||
#[path = "support/tmp.rs"]
|
||||
mod tmp;
|
||||
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use gatewayd::secrets::read_checked;
|
||||
use tmp::TempDir;
|
||||
|
||||
const TOKEN: &str = "the-real-token";
|
||||
const OTHER: &str = "a-file-the-owner-never-chose";
|
||||
|
||||
fn owner_file(dir: &TempDir, name: &str, text: &str) -> PathBuf {
|
||||
let path = dir.write(name, text);
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
fn refused(path: &Path, between: &dyn Fn(), word: &str) {
|
||||
let why = read_checked(path, between).expect_err(word);
|
||||
assert!(why.contains(word), "{word}: {why}");
|
||||
assert!(why.contains(&path.display().to_string()), "{why}");
|
||||
assert!(
|
||||
!why.contains(TOKEN) && !why.contains(OTHER),
|
||||
"never a value: {why}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_untouched_file_is_read() {
|
||||
let dir = TempDir::new("race-ok");
|
||||
let path = owner_file(&dir, "token", &format!("{TOKEN}\n"));
|
||||
let bytes = read_checked(&path, &|| {}).unwrap();
|
||||
assert_eq!(bytes.as_slice(), format!("{TOKEN}\n").as_bytes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_swapped_for_a_link_is_refused() {
|
||||
let dir = TempDir::new("race-link");
|
||||
let path = owner_file(&dir, "token", TOKEN);
|
||||
let other = owner_file(&dir, "other", OTHER);
|
||||
let swap = || {
|
||||
std::fs::remove_file(&path).unwrap();
|
||||
std::os::unix::fs::symlink(&other, &path).unwrap();
|
||||
};
|
||||
refused(&path, &swap, "changed while it was read");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_swapped_for_another_file_is_refused() {
|
||||
let dir = TempDir::new("race-rename");
|
||||
let path = owner_file(&dir, "token", TOKEN);
|
||||
let other = owner_file(&dir, "other", OTHER);
|
||||
let swap = || std::fs::rename(&other, &path).unwrap();
|
||||
refused(&path, &swap, "changed while it was read");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_checks_hold_for_the_file_that_is_read() {
|
||||
let dir = TempDir::new("race-mode");
|
||||
let path = owner_file(&dir, "token", TOKEN);
|
||||
let widen = || {
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
|
||||
};
|
||||
refused(&path, &widen, "has mode 644");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_path_checks_still_come_first() {
|
||||
let dir = TempDir::new("race-first");
|
||||
let other = owner_file(&dir, "other", OTHER);
|
||||
let link = dir.path().join("link");
|
||||
std::os::unix::fs::symlink(&other, &link).unwrap();
|
||||
refused(
|
||||
&link,
|
||||
&|| panic!("never reached for a link"),
|
||||
"is a symbolic link",
|
||||
);
|
||||
refused(
|
||||
dir.path(),
|
||||
&|| panic!("never reached for a directory"),
|
||||
"is not a regular file",
|
||||
);
|
||||
let relative = Path::new("relative/token");
|
||||
let why = read_checked(relative, &|| panic!("never reached")).unwrap_err();
|
||||
assert!(why.contains("is not an absolute path"), "{why}");
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ pub mod hash;
|
||||
pub mod hosts;
|
||||
pub mod ids;
|
||||
pub mod log;
|
||||
pub mod sha1;
|
||||
pub mod tools;
|
||||
pub mod wire;
|
||||
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
//! 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.
|
||||
self.length = self
|
||||
.length
|
||||
.wrapping_add(8u64.wrapping_mul(u64::try_from(data.len()).unwrap_or(u64::MAX)));
|
||||
while !data.is_empty() {
|
||||
let space = 64 - self.filled;
|
||||
let take = data.len().min(space);
|
||||
let (left, right) = data.split_at(take);
|
||||
if let Some(dst) = self.block.get_mut(self.filled..self.filled + take) {
|
||||
dst.copy_from_slice(left);
|
||||
}
|
||||
self.filled += take;
|
||||
if self.filled == 64 {
|
||||
let block = self.block;
|
||||
self.compress(&block);
|
||||
self.filled = 0;
|
||||
}
|
||||
data = right;
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
let saved = self.length;
|
||||
let zeros = if self.filled < 56 {
|
||||
56 - self.filled
|
||||
} else {
|
||||
120 - self.filled
|
||||
};
|
||||
let total = zeros + 8;
|
||||
let mut pad = [0u8; 128];
|
||||
pad[0] = 0x80;
|
||||
pad[zeros..total].copy_from_slice(&saved.to_be_bytes());
|
||||
self.update(&pad[..total]);
|
||||
self.length = saved;
|
||||
|
||||
let mut out = [0u8; 20];
|
||||
for (i, word) in self.state.iter().enumerate() {
|
||||
out[i * 4..i * 4 + 4].copy_from_slice(&word.to_be_bytes());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
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.
|
||||
let mut w: [u32; 80] = [0; 80];
|
||||
let (chunks, _) = block.as_chunks::<4>();
|
||||
for (i, chunk) in chunks.iter().enumerate() {
|
||||
w[i] = u32::from_be_bytes(*chunk);
|
||||
}
|
||||
for i in 16..80 {
|
||||
w[i] = (w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16]).rotate_left(1);
|
||||
}
|
||||
|
||||
let (mut a, mut b, mut c, mut d, mut e) = (
|
||||
self.state[0],
|
||||
self.state[1],
|
||||
self.state[2],
|
||||
self.state[3],
|
||||
self.state[4],
|
||||
);
|
||||
for (round, &ww) in w.iter().enumerate() {
|
||||
let (f, k) = if round <= 19 {
|
||||
((b & c) | (!b & d), 0x5A82_7999)
|
||||
} else if round <= 39 {
|
||||
(b ^ c ^ d, 0x6ED9_EBA1)
|
||||
} else if round <= 59 {
|
||||
((b & c) | (b & d) | (c & d), 0x8F1B_BCDC)
|
||||
} else {
|
||||
(b ^ c ^ d, 0xCA62_C1D6)
|
||||
};
|
||||
let temp = a
|
||||
.rotate_left(5)
|
||||
.wrapping_add(f)
|
||||
.wrapping_add(e)
|
||||
.wrapping_add(k);
|
||||
e = d;
|
||||
d = c;
|
||||
c = b.rotate_left(30);
|
||||
b = a;
|
||||
a = temp.wrapping_add(ww);
|
||||
}
|
||||
|
||||
self.state[0] = self.state[0].wrapping_add(a);
|
||||
self.state[1] = self.state[1].wrapping_add(b);
|
||||
self.state[2] = self.state[2].wrapping_add(c);
|
||||
self.state[3] = self.state[3].wrapping_add(d);
|
||||
self.state[4] = self.state[4].wrapping_add(e);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -1,11 +1,18 @@
|
||||
# cargo-deny configuration. `make gate` runs bans, licenses and sources offline.
|
||||
# `make audit` runs advisories, which fetches the RustSec database.
|
||||
|
||||
# Only the platforms Boxmaker is built on: straylight and the development machines (Linux), and
|
||||
# the owner's Mac. Dependencies for other targets (Windows) are not judged.
|
||||
[graph]
|
||||
all-features = true
|
||||
targets = [
|
||||
"x86_64-unknown-linux-gnu",
|
||||
"x86_64-unknown-linux-musl",
|
||||
"aarch64-apple-darwin",
|
||||
]
|
||||
|
||||
[licenses]
|
||||
allow = ["MIT", "Apache-2.0", "Unicode-3.0"]
|
||||
allow = ["MIT", "Apache-2.0", "Unicode-3.0", "ISC", "BSD-3-Clause"]
|
||||
confidence-threshold = 0.9
|
||||
|
||||
[licenses.private]
|
||||
|
||||
@@ -4,8 +4,11 @@ Every external crate has a row here. `scripts/check-dep-docs.sh` enforces it.
|
||||
|
||||
| Crate | Version | Used by | Why |
|
||||
|---|---|---|---|
|
||||
| `serde` | 1.0.229 | `proto`, `brokerd`, `toolkit` | Derives serialization for every shared type. MIT OR Apache-2.0. |
|
||||
| `serde_json` | 1.0.151 | `proto`, `brokerd`, `toolkit` | JSON for frames and log files. MIT OR Apache-2.0. |
|
||||
| `serde` | 1.0.229 | `proto`, `brokerd`, `toolkit`, `gatewayd` | Derives serialization for every shared type. MIT OR Apache-2.0. |
|
||||
| `serde_json` | 1.0.151 | `proto`, `brokerd`, `toolkit`, `gatewayd` | JSON for frames and log files. MIT OR Apache-2.0. |
|
||||
| `humantime` | 2.4.0 | `proto` | RFC 3339 timestamps. No dependencies of its own. MIT OR Apache-2.0. |
|
||||
| `toml` | 1.1.6 | `proto` tests; `brokerd` and `bxctl` from M3 | Parses grant files. MIT OR Apache-2.0. |
|
||||
| `toml` | 1.1.6 | `proto` tests; `brokerd` and `bxctl` from M3, `gatewayd` from M4a | Parses grant files. MIT OR Apache-2.0. |
|
||||
| `emsha` | 1.0.4 | `proto` | SHA-256. Written by the owner; no dependencies, no `unsafe`. Checked against `sha256sum` on 3,204 inputs. |
|
||||
| `rustls` | 0.23.45 | `gatewayd` | TLS to Mattermost over the tailnet (M4a, owner's decision). `default-features = false` with `ring`, `std`, `tls12`. With its dependencies on Linux: `ring` (Apache-2.0 AND ISC; builds C and assembly), `rustls-webpki` and `untrusted` (ISC), `rustls-pki-types`, `subtle` (BSD-3-Clause), `zeroize`, `once_cell`, `getrandom`, `libc`, `cfg-if`. Apache-2.0 OR ISC OR MIT. |
|
||||
| `rustls-native-certs` | 0.8.4 | `gatewayd` | The host's trusted certificates, so a CA installed on the host is trusted too; adds `openssl-probe`. Apache-2.0 OR ISC OR MIT. |
|
||||
| `zeroize` | 1.9.0 | `gatewayd` | Wipes a secret's memory when it is dropped (`Secret`). Already a dependency of `rustls`. Apache-2.0 OR MIT. |
|
||||
|
||||
@@ -21,7 +21,7 @@ How it is used:
|
||||
| I3 | Report every problem found, not only the first. | M1 finding 8: `check-lines.sh` exits at the first long file. | yes | M2a: not exercised. |
|
||||
| I4 | Run the formatter before the gate, and let it decide ordering. | M1 finding 7: three first-gate failures were rustfmt reordering `lib.rs`. | yes | M2a: no fmt failures; the first-gate failures were clippy. |
|
||||
| I5 | Log every attempt, including one you abandon. | M1 review: the task 01 row mentions files "from a prior attempt" that has no row of its own. | yes | M2a: partly. Task 11's `stopped` row was overwritten by its later `done` row. M2b: no recurrence; all ten rows correct, one with a stray pipe. |
|
||||
| I6 | Prefer a conversion that can fail (`u32::try_from`) to a cast that is only safe because of a check somewhere else (`as u32`). | M1 finding 6: bounded `as` casts in `frame.rs`. Correct today, but the safety depends on a line ten lines away. | no (already implied by the code rules) | M2a: no `as` casts at all. |
|
||||
| I6 | Prefer a conversion that can fail (`u32::try_from`) to a cast that is only safe because of a check somewhere else (`as u32`). | M1 finding 6: bounded `as` casts in `frame.rs`. Correct today, but the safety depends on a line ten lines away. | no (already implied by the code rules) | M2a: no `as` casts at all. M4a: yes, bounded casts in `http.rs` and `handshake.rs`, where the task named no conversion; none where a skeleton comment gave the `try_from`. |
|
||||
| I7 | A `read` returns as soon as it has any data to give. It blocks only when it has none. Never keep reading to fill the caller's buffer. | M2a finding 2: the chunked reader delivered a whole stream at its end. | yes | M2b: no recurrence. |
|
||||
| I8 | When forwarding between two connections, pass a close on in both directions. Do not rely on the client half-closing first. | M2a finding 1. | no (specific to inferproxy) | |
|
||||
| I9 | Never end a turn by describing what you are about to do. Do it, then report. | Two Ornith turns in M2a ended with a "## Objective" plan and no tool call. | yes | M2b: not seen; all ten tasks ran unattended to a commit. |
|
||||
@@ -59,12 +59,14 @@ How it is used:
|
||||
| T22 | List the fail-closed states a task creates, with their runbook anchors, in the task itself. A script can check that a pointer names an existing entry; nothing can check for a pointer that was never written. | M3a finding 3: four startup failures in `serve` and `main` print no pointer, and the spec's own list of pointers omitted them. |
|
||||
| T23 | A test that writes a script and then runs it must hold a lock shared by every test in its binary that starts a process. Otherwise another test's fork can hold the new script open for writing at that moment, and running it fails with "Text file busy" (ETXTBSY), about once in seven runs. Give the lock in the support file and say why. | M3b plan checks: the fake `curl` tests failed 8 times in 40 until every forking test took `serial()`; then 0 in 60. |
|
||||
| T24 | A task that changes a `Cargo.toml` must stage `Cargo.lock` in its `git add` line. Better, put `Cargo.lock` in every task's `git add`; it is a no-op when unchanged. | M3b task 04: committed correctly, left the lock behind, and the driver stopped on an unclean tree. |
|
||||
| T25 | Size a task by the largest function the model must hold in one turn, not by the task. Ornith writes one function with a few branches well; a function with half a dozen branches and threads (M3b's `run`) it plans in its head until the turn runs out, with nothing written. Give such a task a compiling skeleton with the big function already written as glue over small `todo!()` helpers, and say to fill one at a time with `cargo check` between. | M3b task 11: four sessions. Two wrote nothing; a whole-file skeleton got five of six functions; the finer skeleton finished it in ten minutes, and tasks 12 and 13 followed without a stop. |
|
||||
| T25 | Size a task by the largest function the model must hold in one turn, not by the task. Ornith writes one function with a few branches well; a function with half a dozen branches and threads (M3b's `run`) it plans in its head until the turn runs out, with nothing written. Give such a task a compiling skeleton with the big function already written as glue over small `todo!()` helpers, and say to fill one at a time with `cargo check` between. | M3b task 11: four sessions. Two wrote nothing; a whole-file skeleton got five of six functions; the finer skeleton finished it in ten minutes, and tasks 12 and 13 followed without a stop. M4a task 08: again, a skeleton whose `header` was one `todo!()` with a dozen branches; nothing written. Size every `todo!()`, not only the task: the largest comment above a `todo!()` is a fair proxy, and one over about six lines needs splitting. M4a task 14: a task with a dozen small `todo!()`s still stalled, because the model read the whole crate to learn the APIs first; give a table of every call the task makes, with its signature, and make copying and the failing test the first two actions. |
|
||||
| T26 | Replay each task's end state on its own, **and** read the task file against the reference for anything the reference has that the task does not ask for. The replay proves the tests can pass; only the reading finds a field the reference reads through a getter the task never mentions. | M3b task 11: the task stored `egress_dir` without the reference's getter, so the field was never read and clippy failed; the replay passed because the reference had the getter. |
|
||||
|
||||
| T27 | Every wait in a given test has a limit (`recv_timeout`, a deadline loop, `is_finished` before `join`). A test that waits forever on a broken implementation hangs the driver instead of failing, and the implementer cannot tell a hang from slowness. | M4a planning: breaking the reference's ping made `ws_conn` hang on `rx.recv()`; retrying a refused token made the serve test hang on `join()`. Both now fail within 5 s. |
|
||||
| T28 | Before hand-over, break the reference on purpose, one line at a time (drop a check, move a bound by one, skip a save), and run the given tests against each change. A change the tests still pass is a missing test, unless it cannot change behaviour. Rustfmt reflows lines: match on text that survives formatting. | M4a planning: 69 changes over six modules; the tests missed 8. Five were real gaps and got tests (a reconnect that re-sent "interrupted" for a running turn was one), two could not change behaviour, and one was left (an event frame with another id, which `loopd` never sends). |
|
||||
|
||||
| T29 | Check a skeleton's comments, not only its code. Print every `todo!()` beside its signature and read each pair before hand-over. Filling the bodies from your own code proves the split can pass; it cannot show that a comment sits on the wrong function. Edit comments by function, never by "the next comment after this name". And tell the implementer to stop and quote a comment that does not fit. | M4a task 14, second session: `post`'s comment was `now_ms`'s text, put there by a script; Ornith saw the contradiction and deliberated over it until the turn ran out. M4a task 16: a comment naming the function it replaced made the task's own `grep` fail; and a given test copied into the plan before `cargo fmt` came back reformatted. Run the task's checks, and `cargo fmt`, on the end state before hand-over. |
|
||||
|
||||
## What worked and should be kept
|
||||
|
||||
- Byte-exact fixtures, compared in both directions. No wire-format defect reached review.
|
||||
@@ -102,3 +104,9 @@ gate 4 of 10, and four low findings in review, two of which are the "everywhere"
|
||||
a new shape (paths instead of types). Its code reads well and follows the numbered steps in the
|
||||
tasks closely; where the task did not walk a path, the rule was not applied there. Task 11 fixed all four in one run and reported its one
|
||||
deviation, which the task itself had caused.
|
||||
|
||||
M4a was Ornith alone, fifteen tasks through the driver: first gate 12 of 15, four review findings, none
|
||||
above low. Given a skeleton whose comments name the calls and expressions, it writes exactly that,
|
||||
and its code agreed with the reference on every fuzzed input. Given a `todo!()` with many branches,
|
||||
or a task that needs the whole crate's API, it plans in prose until the turn runs out; given a
|
||||
comment that contradicts its function, it notices but deliberates rather than stopping (T29).
|
||||
|
||||
@@ -6,6 +6,22 @@ reviewer adds findings under "Reviews" once per milestone.
|
||||
|
||||
| Task | Date | Status | Gate runs | First gate | Deviations | Notes | Model |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| M4a/16-gatewayd-secret-file-race | 2026-09-24 | done | 2 | pass | Reworded the template comment in `read_checked` to drop the literal `check_file` token, so step 5's grep prints only the credential branch's `std::fs::read(&path)` | Replaced `check_file` with `pub fn read_checked(path, between) -> Result<Zeroizing<Vec<u8>>, String>`: check the path (absolute; `symlink_metadata` as `named`; not a symlink; a regular file), call `between()`, open the path, take `opened` metadata, refuse if `(opened.dev(), opened.ino()) != (named.dev(), named.ino())`, then the owner-uid and mode checks on `opened` (not `named`), then read the whole file into a `Zeroizing<Vec<u8>>` from that same handle. Added `use std::io::Read;`. In `load`, the File branch now calls `read_checked(path, &|| {})` and `value(std::mem::take(&mut *bytes))` so nothing unwiped remains. The three suites pass (5, 8, 5); the only remaining `std::fs::read(` is the credential branch and `check_file` is gone. `make gate` prints `gate: ok`. | ? |
|
||||
| M4a/15-gatewayd-main | 2026-09-24 | done | 1 | pass | none | Replaced the placeholder `src/main.rs` with the skeleton and copied `tests/main.rs`. `main` parses `args_os` like `loopd`'s; `serve` runs the five start checks in order — `Config::load` (`gatewayd: {e}\n{START_FAILED}`), `token_source` (`gatewayd: {}: {why}\n{START_FAILED}` with the path), `secrets::load` (`gatewayd: {e}`, its own `#secret-unavailable` pointer, then the file warning if any), then the state dir via `DirBuilder` recursive + `mode(0o700)` (`gatewayd: cannot prepare {}: {e}\n{START_FAILED}`), each returning exit 1 before anything is written. Then `run` with a `Log` that echoes to stderr and the returned `Stop` printed, exit 1. The todo comment matches the function and every test; the token never reaches output (it is only ever `expose`d inside `serve`, never here). `cargo test -p gatewayd` every suite passes; `make gate` prints `gate: ok` first run. | ? |
|
||||
| M4a/14-gatewayd-serve | 2026-09-24 | done | 1 | pass | none | Copied `tests/serve.rs`, `tests/serve_restart.rs`, `tests/support/fake_mm.rs`, `tests/support/gateway.rs` and the `src/serve/mod.rs`/`handle.rs` skeletons, added `pub mod serve;` to `lib.rs` (before `secrets`, alphabetical). Filled `mod.rs`: `From<StateError> for Stop` → `Stop::State(e)`; `backoff` (the last backoff repeats past its end); `sleep_unless` (interruptible 20 ms steps until the deadline); `Gateway::connected` (log "connected to <url> as <username>", route as this user from `users/me`, and on the first connection only, answer each in-flight turn with INTERRUPTED so a later reconnect does not). Filled `handle.rs`: `now_ms` (millis with an i64 clamp); `post` (a failing post logs, never throws); `tracked` ("D" or an allowed channel); `handle_post` (skip untracked/seen, record `handled` before routing so a crash never answers twice, then route with the state answering `knows_thread` — an `NotAllowed` stranger is logged by post id and user id only, never the text, a reply posts, a queued message joins the thread then pushes with Start/Waiting/Full); `start` (record the in-flight turn, spawn `deliver` on its own thread, and on a spawn error post LOOP_DOWN and un-busy the session); `finished` (drain done, end each turn, start the next batch); `typing` (a `user_typing` per running thread, seq incremented); `catch_up_channel` (no mark → mark "now" and stop, history not answered; else `posts_since`, log when `full`, replay each post). `SessionId` has no `Display`, so the start-error line formats `session.as_str()`. `serve` 6 and `serve_restart` 7 pass five runs in ~1.3 s; `make gate` prints `gate: ok` first run. | ? |
|
||||
| M4a/13-gatewayd-deliver | 2026-09-23 | done | 1 | pass | none | Copied `tests/deliver.rs`, `tests/support/fake_loop.rs` and the `src/deliver.rs` skeleton, added `pub mod deliver;` to `lib.rs` (before `http`, alphabetical). Filled `error_text` (the snake_case name serde gives the ErrorCode via `serde_json::to_value`, falling back to `<unknown>` without ever formatting `ErrorCode` with `{}` since it has no Display); `split_answer` (blank/whitespace-only → [EMPTY_ANSWER]; else while the rest is longer than `MAX_POST` *characters*, cut at the last newline within the first `MAX_POST` chars past position 0 — dropping that newline — else at the byte index of the `MAX_POST`-th char via `char_indices().nth(MAX_POST)`, never inside one char); `one_turn` (connect fails → LoopDown("cannot connect to <path>: <e>"), write one id-1 final Turn envelope (write error → LoopDown), then read: (1, not final, TurnEvent)→on_event, (1, final, TurnDone)→Answer(content), (1, final, Error)→Refused, a read error → LoopDown("the turn ended early: <e>"), anything else → LoopDown("an unexpected frame")); `run_turn` (one_turn with `batch.resume`, and when Refused(NoSuchSession) with `batch.resume` true, one more with resume false to create the session, as `bxctl chat --session`); `deliver` (post in the thread — ApprovalPending posts `approval_text` at once, then Answer→every split part in order, Refused→error_text, LoopDown→log "gatewayd: <session>: <why>" and post LOOP_DOWN; a failing post logs "gatewayd: cannot post in <channel> (thread <root>): <e>" through a small `post` helper). All 9 tests pass; `make gate` prints `gate: ok` first run. | ? |
|
||||
| M4a/12-gatewayd-state | 2026-09-23 | done | 1 | pass | none | Copied `tests/state.rs` and the `src/state.rs` skeleton, added `pub mod state;` to `lib.rs`. Filled `problem` (every `channels` key, `recent` and `threads` entry must be `valid_id` → "not a Mattermost id: <id with {:?}>"; each `in_flight` entry, session `mm-`+valid id with channel and root valid → "a turn in flight is not valid: <session with {:?}>"); `load` (NotFound → empty StateFile, any other read error, serde parse error or `problem()` → Read(path, why)); `save`/`persist` (the six atomic steps of brokerd's persist, io error mapped to Write, old file left on failure); `handled` (records unseen ids keeping the newest RECENT_KEPT, moves the channel mark to the max), `mark` (sets only a channel without a mark), `join_thread` (keeps the newest THREADS_KEPT), `start_turn`/`end_turn`/`take_in_flight` (removing by session, save only when in_flight was non-empty). Every mutating method saves before returning. All 4 tests pass in ~0.04 s; `make gate` prints `gate: ok` first run. | ? |
|
||||
| M4a/11-gatewayd-sessions | 2026-09-23 | done | 2 | fail | Removed the `resume` field from the copied `Pending` struct (written by the skeleton but never read) | Filled `named` (byte scan for `@`, the longest run of ASCII alnum/`.` `-` `_` after it, trailing `.` trimmed, lower-cased, empty runs skipped, then continue past the name) and the `Router` (`route` is a straight line of early returns in spec order: own -> Ignore(Own); kind not empty -> System; user not in allow.users -> NotAllowed; then the channel where "D" is always ours and "O"/"P"/"G" needs an allowed channel id plus `for_us`; the thread root is `root_id` or the post id; `!!...` keeps one `!` and queues, a lone `!` or `!approve`/`!deny` is a command where approve/deny answers M4B_COMMAND and anything else UNKNOWN_COMMAND, a command never reaches loopd; then Queue with session `mm-<root>`, `resume = root_id not empty`, `joins_thread = channel_type != "D"`). `for_us` is true when it names this bot (case-insensitive), otherwise a reply in a known thread that names only channel/here/all. `Queues`: `push` starts a turn when idle (Start with that message alone), waits while running and returns Full(thread) at the limit; `finish` joins the waiting texts with "\n\n" as a resume:true Batch, clears the queue and stays running, removing the session when nothing waits. `Pending.resume` was dead code (the next turn is always a continuation so `finish` hardcodes resume:true) so I removed it rather than allow a lint. First gate failed on clippy `manual_strip`; switched `starts_with("!!")`/`starts_with('!')` plus `&message[2..]`/`&message[1..]` slicing to `strip_prefix`. All 8 sessions tests pass; `make gate` prints `gate: ok` on the second run. | ? |
|
||||
| M4a/10-gatewayd-mm | 2026-09-23 | done | 1 | pass | none | Copied `tests/mm_json.rs`, `tests/mm_rest.rs` and `tests/support/http_server.rs`, added the `src/mm/mod.rs` and `src/mm/rest.rs` skeletons to `crates/gatewayd/src/mm/` and `pub mod mm;` to `lib.rs` (before `secrets`, alphabetical). Filled `mod.rs`: `Post::check` requires `id`/`user_id`/`channel_id` `valid_id` and `root_id` empty-or-`valid_id`, else `Json` quoting the offending id with `{:?}`; `json` is `serde_json::from_slice` mapped to `Json(e.to_string())`; `parse_event` matches `hello`/`posted`/other — `posted` takes `data.post` as a JSON *string* (an object or missing is `Json`), parses it, `check`s it, and reads `data.channel_type` (else ""), any other name (or an empty-name reply) is `Other(name)`; `typing` is `serde_json::json!` compacted; `since_list` walks `order` only (skipping ids not in `posts`, keeping `create_at > since && delete_at == 0` after `check`, deduping, then sorting by `(create_at, id)`), `full` when `order.len() >= SINCE_LIMIT`. Filled `rest.rs`: `Client::new` stores the three fields; `once` connects within `timeout`, sets the read timeout, sends `Authorization: Bearer <token>` (the only `expose`), `Accept`/`Content-Type` headers and `host_header`, mapping every error to `Net("<method> <path>: <e>")`; `call` loops `once` — 2xx returns the body, 401/403 `Auth`, 429 waits `rate_limit_wait` up to `RETRIES` then `RateLimited`, 5xx retried up to `RETRIES` times sleeping `RETRY_5XX`, else `Status` with the first `BODY_KEPT` lossy-UTF-8 chars via a `status_error` helper; `me`/`create_post`/`posts_since`/`direct_channel` build the four calls, `posts_since` and `me`/`direct_channel` reject non-`valid_id` ids as `Json` before sending. All 7 `mm_json` and 10 `mm_rest` tests pass (the latter ~3 s on two deliberate rate-limit waits); `make gate` prints `gate: ok` first run. | ? |
|
||||
| M4a/09-gatewayd-ws-conn | 2026-09-23 | done | 1 | pass | none | Filled the eight functions in the copied `crates/gatewayd/src/ws/conn.rs` skeleton (the written `poll` was the glue). `open`: `connector.connect(dead_after)` mapped to `Handshake(e.to_string())`, then `handshake` with `host_header(connector.server())`, a Ws with a new `Decoder` and `last_heard`/`last_ping` both `now`. `send`: `read_exact` 4 mask bytes from `random`, then `encode(opcode, payload, mask)` written and flushed. `send_text`: `send(TEXT, text.as_bytes())`. `take_messages`: loop `next_message`, `Text` returns, `Ping` answered with `send(PONG, &payload)`, `Pong` ignored, `Close` replies the code as 2 big-endian bytes (empty when none) via a best-effort `send(CLOSE, ...)` (the peer may be gone) and returns `Closed`. `keep_alive`: `now.duration_since(last_heard) >= dead_after` is `Dead`, else `now.duration_since(last_ping) >= ping_every` pings and stamps `last_ping`. `read_timeout`: the least of next-ping, next-dead and until-left (each `saturating_duration_since`), then `.max(1ms)`. `read_some`: `set_read_timeout`, a 16 KiB buffer, `Ok(0)` -> `Closed`, `Ok(n)` feeds `buf.get(..n).unwrap_or_default()` and stamps `last_heard`, `WouldBlock`/`TimedOut`/`Interrupted` -> `Ok(())`, any other `Err` -> `Io`. `close`: best-effort `send(CLOSE, &1000u16.to_be_bytes())`. `host_header`: host alone when the port is the scheme default (443 for tls, 80 otherwise) else `host:port`. All 10 tests in `tests/ws_conn.rs` pass five runs under a second; `make gate` prints `gate: ok` first run. | ? |
|
||||
| M4a/08-gatewayd-ws-frames | 2026-09-23 | done | 1 | pass | none | Copied `tests/ws_frame.rs` and the `src/ws/frame.rs` skeleton, added `pub mod frame;` (before `handshake`, alphabetical). Filled the seven functions the comments specified verbatim: `check_first_bytes` (reserved bits `b0 & 0x70`, mask `b1 & 0x80`, opcode `matches!(b0 & 0x0F, CONTINUATION | TEXT | CLOSE | PING | PONG)`); `check_control` (not fin, then >125); `length` (match on `short`: 0..=125, 126 reading `buf.get(2..4)` into `u16::from_be_bytes` with `len < 126` refused, 127 reading `buf.get(2..10)` into `u64::from_be_bytes` with top-bit and `<= 0xFFFF` refused, `usize::try_from(len).unwrap_or(usize::MAX)`); `check_data` (TEXT while partial, CONTINUATION while none, `payload_len > MAX_MESSAGE.saturating_sub(so_far)`); `data_frame` (empty Vec for TEXT else `partial.take().unwrap_or_default()`, append, defer when not fin, `String::from_utf8` at fin); `close` (slice-pattern match, `u16::from_be_bytes` code, UTF-8 reason); `encode` (FIN+opcode, three length branches with mask bit, XOR with `mask.iter().cycle()`). All string literals got `.to_string()` for the `Protocol(String)` variant, matching http.rs. `length`'s match needed a defensive `_` arm (`128..=u8::MAX` unreachable since `short = b1 & 0x7F`) so the codec stays exhaustive without a panic. All 7 tests in `tests/ws_frame.rs` pass including the 300-seed property test against the naive decoder; `make gate` prints `gate: ok` first run. | ? |
|
||||
| M4a/07-gatewayd-ws-handshake | 2026-09-23 | done | 1 | pass | none | Filled the copied `crates/gatewayd/src/ws/handshake.rs` skeleton. `base64`: 3-byte chunks to 4 chars over ALPHABET with `=` padding, reading each byte via `first`/`get(..).copied().unwrap_or(0)` (no indexing) and masking to 0..=63 before the alphabet index; `accept_for`: `base64(sha1(key + GUID))` building `key+GUID` into one Vec; `new_key`: `read_exact` 16 bytes (too few is an io error) then base64; `check_response` in the task's order — status 101 (`"status <n>"`), `Upgrade` == `websocket` (ASCII case-insensitive), `Connection` with a comma-split token == `upgrade` (case-insensitive), then `Sec-WebSocket-Accept` exactly == `accept_for(key)`; `handshake`: `new_key`, write `request_text` + flush, `read_head` (mapped to `Handshake`), `check_response`, reading nothing past the head. All 7 tests in `tests/ws_handshake.rs` pass including the RFC 6455 accept vector and the first-frame-left-unread handshake; `make gate` prints `gate: ok` first run. | ? |
|
||||
| M4a/06-gatewayd-http | 2026-09-23 | done | 1 | pass | none | Filled the copied `crates/gatewayd/src/http.rs` skeleton. `Head::header`: first name match, ASCII case-insensitive. `write_request`: builds the head into one buffer in the exact order (`<method> <path> HTTP/1.1`, `Host:`, the given headers, `Content-Length: <n>` only when there is a body, `Connection: close`), writes it then the body, flushes. `request`: `write_request`, `read_head`, `read_body`. `read_head`: reads one byte at a time, retrying `Interrupted`, returning `Protocol` on EOF and `TooLarge("head")` once past `MAX_HEAD`, stopping exactly at `\r\n\r\n`; parses UTF-8, a `HTTP/1.1`/`HTTP/1.0` status line with a 3-digit code in 100..=599 (`split_whitespace`, so `2000`/`abc`/`99`/`HTTP/2` all fail), then header lines `name: value` (non-empty name without a space, value trimmed) until the first blank line. `read_body`: chunked via `read_chunked` when `Transfer-Encoding: chunked` (any case), else `Content-Length` parsed as its own digits (else `Protocol`, over `MAX_BODY` is `TooLarge("body")`, then `read_exact`), else read to the end through `take(MAX_BODY + 1)`. `read_chunked`: hex size before any `;` (1024-byte cap), size 0 reads 8 KiB trailer lines until an empty one, otherwise the size must fit in `MAX_BODY - already_read` (else `TooLarge`) followed by exactly a blank line; `parse_hex` uses `checked_mul`/`checked_add` so an overflow past u128 is `Protocol`. `rate_limit_wait`: `X-Ratelimit-Reset` as u64, above 1_000_000_000 a Unix time (`saturating_sub` elapsed since epoch, at least 1 s) else seconds (at least 1), missing or non-numeric 1 s, capped at `MAX_RATE_WAIT`. All 6 tests in `tests/http.rs` pass; `make gate` prints `gate: ok` first run. | ? |
|
||||
| M4a/05-gatewayd-net | 2026-09-23 | done | 1 | pass | none | Filled the copied `crates/gatewayd/src/net.rs` skeleton. `Stream::tcp`: match on the variant, `s` for Plain, `s.get_ref()` for Tls. `set_read_timeout` and `Read`/`Write`/`flush` forward to the inner stream per variant. `Connector::new`: for `server.tls` true, `Arc::new(client_config(ca_file)?)` (a bad `ca_file` or empty host certs is `Roots`, before any connection); for false, `None`. `Connector::server` returns `&self.server`. The written `connect` resolves the host, tries each address, sets read/write timeouts + nodelay, and for TLS runs `complete_io` in a loop so a bad cert fails at connect. All 7 tests in `tests/net.rs` pass (plain TCP; TLS via `ca_file`; unknown CA and wrong name refused at connect; TLS to a plain server fails without hanging; bad `ca_file` refused before connecting; nothing listening); `make gate` prints `gate: ok` first run. Added `rustls` to `[dev-dependencies]` for the test TLS server. | ? |
|
||||
| M4a/04-gatewayd-secrets | 2026-09-23 | done | 1 | pass | none | Filled the copied `crates/gatewayd/src/secrets.rs` skeleton. `value`: `from_utf8` else "the value is not UTF-8", one trailing `\n` stripped with `strip_suffix`, empty refused, raw bytes kept in `Zeroizing` until inside the `Secret`. `check_file` in the given order: not absolute, `symlink_metadata` else "cannot read <path>", symlink via `file_type().is_symlink()`, not a regular file via inherent `is_file()`, owner uid compared to `/proc/self`'s uid (`MetadataExt`), then `mode & 0o077 != 0` reporting the mode as `{:03o}`. `load` matches the three `SecretSource` forms, reading `CREDENTIALS_DIRECTORY` and the variable through the passed `env` closure (never `std::env`), every failure wrapped in `SecretError` naming the secret and never the value, file secrets setting the exact plaintext warning. Replaced the skeleton's `PermissionsExt` import with `MetadataExt` and used inherent `FileType::is_file`/`is_symlink` (Rust 1.98) so no `FileTypeExt`, `unsafe` or `libc`. All 8 tests in `tests/secrets.rs` pass; `docs/runbook.md` gained the seven gatewayd fail-closed entries (14→21 `## ` lines) and `scripts/check-runbook.sh` exits 0. | ? |
|
||||
| M4a/03-gatewayd-config | 2026-09-23 | done | 2 | fail | none | Filled the copied `crates/gatewayd/src/config.rs` skeleton. `ConfigError::fmt`: `"<path>: <why>"` with `path.display()`. `load`: read (else `Read`), `toml::from_str` (else `Parse`), then `problem()` (Some is `Invalid`). `parse_url`: strip `https://`/`http://`, `rsplit_once(':')` for an optional port, `valid_host` (1..=253 bytes of a-z 0-9 . -, not starting/ending with . or -) and `parse_port` (digits, 1..=65535, equal to its own `to_string()`, via `u16::try_from`); every failure returns one `[mattermost] url "<url>" must be...` message. `SecretSpec::source`: count the set fields (else "needs exactly one"), then validate credential (a-z0-9 _ . -), env (A-Z0-9 _) and file (absolute) in turn. `problem` checks url, ca_file, missing token, each secret's `source()`, empty users, ids in users then channels, then limits queue/typing/ping/dead. `valid_id` is 26 bytes of a-z0-9; `loop_socket` falls back to `<home>/run/loop/loop.sock`; `state_path` is `<home>/gateway/state.json`. All 7 config tests pass; `make gate` prints `gate: ok`. | ? |
|
||||
| M4a/02-gatewayd-deps | 2026-09-23 | done | 1 | pass | none | Added the dependencies gatewayd needs for TLS to Mattermost and nothing that uses them yet. Added `rustls` (0.23.45, `default-features = false` with `ring`/`std`/`tls12`), `rustls-native-certs` (0.8.4) and `zeroize` (1.9.0) to `[workspace.dependencies]` in the root `Cargo.toml`, the three plus `serde`/`serde_json`/`toml` to `crates/gatewayd/Cargo.toml`, copied `deny.toml` and the whole `crates/gatewayd/tests/fixtures/tls/` directory (9 files), replaced the one-line doc comment in `lib.rs` with the M4a spec doc, and in `docs/dependencies.md` added `gatewayd` to the `serde`/`serde_json`/`toml` rows and appended the `rustls`/`rustls-native-certs`/`zeroize` rows. `cargo build -p gatewayd` succeeded offline (all crates already in the local cache). cargo-deny reported `bans ok, licenses ok, sources ok`. `make gate` printed `gate: ok` on the first run. | ? |
|
||||
| M4a/01-proto-sha1 | 2026-09-23 | done | 2 | fail | none | Wrote `crates/proto/src/sha1.rs`: `sha1` (new/update/finish), `Sha1 { state, block, filled, length }` with `length` counting bits. `compress`: `w: [u32; 80]` via `as_chunks::<4>()` + `from_be_bytes`, `w[i] = (w[i-3]^w[i-8]^w[i-14]^w[i-16]).rotate_left(1)`, eighty wrapping rounds with f/k by range, state added with `wrapping_add`. `update`: `wrapping_add(8u64.wrapping_mul(data.len() as u64))`, `split_at`/`get_mut(..).copy_from_slice`, copy the block out (`let block = self.block`) before `compress` so the mutable receiver and shared slice do not clash. `finish`: builds a 128-byte pad (`0x80`, zeros, 8 big-endian length bytes) sized `56-filled` or `120-filled` plus the 8 length bytes, feeds it through `update`, restores `length`, then the five words big-endian. One logic bug caught by the empty-string vector: the `w[i]` expansion rotated only `w[i-16]` instead of the whole XOR, fixed with parentheses. First gate failed on clippy `needless_range_loop` for the 0..80 round loop; switched to `w.iter().enumerate()` with a bound `&ww`. All 4 sha1 tests pass; `make gate` prints `gate: ok`. | ? |
|
||||
| M3b/17-toolkit-nits | 2026-09-23 | done | 1 | pass | none | Three small fixes. `fetch.rs`: replaced `std::thread::spawn` with a `Builder::new().spawn` match that kills and waits on a spawn error and returns `Outcome::tool_error("http_fetch: cannot start a thread: {e}")`. `input.rs` and `files.rs`: replaced `MAX_INPUT as u64 + 1` / `MAX_READ as u64 + 1` with `u64::try_from(MAX_*).map_or(u64::MAX, |n| n.saturating_add(1))`. `main.rs` `parse_egress_proxy`: the first check is now `args.len() != 5 || args.first()?.as_bytes() != b"egress-proxy"` so a longer list goes to the tool form (exit 2). Copied `tests/egress_form.rs`; the one test failed before the fix and passed in 0.02s after. `grep "thread::spawn\| as u64" crates/toolkit/src/` prints nothing. `make gate` prints `gate: ok` first run. | ? |
|
||||
| M3b/16-brokerd-log-escaping | 2026-09-23 | done | 1 | pass | none | Escaped the container's standard error before logging it (M3b review finding 5) and prefixed/quoted three small texts (finding 7). `container.rs` `answer`: exit 2 logs `brokerd: {name}: the tool could not run: {err:?}` instead of the raw stderr; exit 125..=127 logs `brokerd: podman could not start {name}: {err:?}\n{RUNBOOK}` (was `{err}\n{RUNBOOK}`); the `_` arm logs `brokerd: container {name} exited {status}: {err:?}` (the trailing `\n{err}` moved inside the debug format). `start_egress`: the proxy's `podman run -d` failure now logs `{stderr:?}`. `main.rs`: the runtime notice prints `brokerd: {runtime_notice}`. `config.rs`: the image and memory `[runner]` errors use `{:?}` so the bad value is quoted. Copied `tests/container_log.rs` and `tests/notices.rs`; 4, 2, 2 and 7 passed; `make gate` prints `gate: ok` first run. | ? |
|
||||
| M3b/15-brokerd-pipes-grace | 2026-09-23 | done | 1 | pass | none | Fixed the two M3b review findings in `pipes.rs`. Added `pub const GRACE: Duration = Duration::from_secs(2)`; replaced the `JoinHandle`-holding `Io` with one holding `Option<Receiver<(Vec<u8>, bool)>>` for stdout/stderr and added the `Finished { out, truncated, err, open }` struct. `Io::start` now returns `std::io::Result<Io>`: the stdin writer is started with `Builder::new().spawn(...)` and its handle dropped (never joined, a spawn error returned with `?`); a new private `reader(pipe, cap) -> io::Result<Receiver<...>>` starts one detached reader per pipe and returns a spawn error, used for both stdout (`cap`) and stderr (`err_cap`). `Io::finish(grace)` sets one `until = Instant::now() + grace` and calls a private `collect(rx, until)` per receiver: `recv_timeout(until.saturating_duration_since(now))` → `Ok` keeps bytes, `Timeout` sets `open`, `Disconnected` (panicked reader) counts closed; `open` is `stdout_open || stderr_open`. `read_capped` unchanged in behavior, now `cap.saturating_sub(kept.len())` and `chunk.get(..take).unwrap_or_default()` (no indexing, no wrapping subtraction). `container.rs`: added `pub const OUTPUT_OPEN` after `TIMED_OUT`, `use crate::pipes::{GRACE, Io}`, and replaced the `run_container` tail with the task's `Io::start` match (on spawn error, `podman kill`/`rm -f`, `child.kill()`/`wait()`, log + `Unavailable(CANNOT_START)`), then `io.finish(GRACE)` and the `done.open && status.is_some()` check returning `Failed(OUTPUT_OPEN)` (a timed-out call, `status` None, still answers `TIMED_OUT`). All `std::thread::spawn` gone from `crates/brokerd/src/`. Copied `tests/container_grace.rs`; 4 passed in ~4.4s, container 11, container_egress 6, all five runs; `make gate` prints `gate: ok` first run. | ? |
|
||||
@@ -504,3 +520,68 @@ address of its own), and M3a findings 17, 18 and 20. Owner foot-guns noted by th
|
||||
and not fixed: a grant path that overlaps a container path (`/bin`, `/tmp`, `/run/egress`), and a
|
||||
`home` containing `:`.
|
||||
|
||||
|
||||
### M4a, tasks 01 to 15 — reviewed 2026-09-24 by the design model (Claude)
|
||||
|
||||
Accepted. All work by Ornith-1.5-35B-A3B through `tools/run-plan.sh`. `gatewayd` does what the
|
||||
spec says, and against the owner's Mattermost, with the owner sending the messages, every claim of
|
||||
the milestone held.
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| 15 task commits, each with the trailer; 3 plan commits by the design model during the run | pass |
|
||||
| All given tests, fixtures, `deny.toml` and the runbook entries identical to the plan | pass |
|
||||
| Protected files (`AGENTS.md`, `CLAUDE.md`, `docs/design.md`, specs, `Makefile`, `scripts/`) | untouched |
|
||||
| `make gate` | `gate: ok`, 762 tests, the count the plan gave |
|
||||
| Differential fuzz against the reference (scratch, not kept): 40,000 random frame streams in random pieces, 20,000 HTTP responses, 50,000 strings for `named`, 300 answers for `split_answer` | frames, encoding, names and splits identical; HTTP differs only in leniency (finding 3) |
|
||||
| Banned constructs in new library code | no `unwrap`, `expect`, `panic!` or `#[allow]`; bounded `as` casts in `http.rs` and `handshake.rs` (finding 1) |
|
||||
|
||||
**Against the owner's server** (2026-09-24; this branch's binaries on the owner's machine,
|
||||
`inferproxy`, `brokerd` without `[runner]` and `loopd` against Ornith slot 0 on straylight,
|
||||
`gatewayd` against `https://straylight.scylla-hammerhead.ts.net`):
|
||||
|
||||
| Claim | Seen |
|
||||
|---|---|
|
||||
| A direct message is answered in its thread, typing shown meanwhile | "What is 17 × 23?" answered "391" in a second; the owner saw the bot typing |
|
||||
| A thread is one session; a new top-level message is a new one | three threads, three `mm-<root>` sessions in `loopd` |
|
||||
| Messages sent during a turn go together as the next turn | three replies sent during a long answer reached `loopd` as one turn, joined by blank lines |
|
||||
| A long answer is split | a 4,000-word essay came back as two posts, in order |
|
||||
| Commands | `!approve 1` answered with the M4b notice and never reached `loopd`; `!!approve is just a word` reached it as `!approve is just a word` |
|
||||
| In a channel: only posts that name the bot, or replies in its thread naming nobody else | a plain post ignored; a post naming the bot and an unnamed reply in its thread answered; a reply naming `@agent-bot` ignored |
|
||||
| Anyone else gets nothing | a direct message from the second account: no reply, no typing; one log line with the post and user ids, no text |
|
||||
| A restart reports the cut-off turn and answers what came meanwhile | `gatewayd` stopped mid-turn; "Are you back?" sent while it was down; at restart, "interrupted: …" in the cut-off thread, then "Are you back?" answered; the cut-off answer is in `loopd`'s session log |
|
||||
| No listening port | `ss`: no listening socket; one outbound connection, to 443 |
|
||||
| The token comes from an encrypted systemd credential and is never printed | `systemd-creds --user encrypt`, then `systemd-run --user -p LoadCredentialEncrypted=…`: connected; the token is in no output of any run |
|
||||
|
||||
| # | Severity | Owner | Finding | Fix |
|
||||
|---|---|---|---|---|
|
||||
| 1 | nit | implementer (06, 07) | `as` casts on values already bounded a few lines up: five in `http.rs` (chunk sizes, `MAX_BODY`), several in `handshake.rs`'s base64. None can lose data; tip I6 prefers `try_from`. | Fixed by the design model at the owner's request, with a third the review missed in `sha1.rs` |
|
||||
| 2 | low | spec, reference and implementer (04) | A secret file is checked with `symlink_metadata` and then opened by path, so someone who can write to its directory could swap it for a symbolic link in between. The reference had the same (tip T5). Opening once with `O_NOFOLLOW` and checking the open file closes it. | Task 16 |
|
||||
| 3 | nit | implementer (06) | The HTTP reader is more lenient than the reference: two spaces in the status line, and a bare `\n` ending a chunk line, are accepted. Every hostile case is still refused before any allocation. | Fixed by the design model at the owner's request: single spaces in the status line, CRLF on every chunk line (`tests/http_strict.rs`, red before) |
|
||||
| 4 | nit | plan (11) | The skeleton's `Pending.resume` was read only in a `todo!()` whose comment did not mention it; the implementer wrote `resume: true` directly, then removed the unused field and reported it (tip T26). | None needed |
|
||||
|
||||
What was good: after the skeletons were split, every task finished on its first session; the
|
||||
decoder, the HTTP reader and the state file follow their comments exactly and agree with the
|
||||
reference on every fuzzed input; the one deviation was reported.
|
||||
|
||||
**The run.** 12 of 15 tasks passed the gate on their first run. Three sessions ended with nothing
|
||||
written, all three from the plan: task 08's `header` was one `todo!()` with a dozen branches (tip
|
||||
T25); task 14 made the model read the whole crate to learn its calls (T25, a call table); and task
|
||||
14's second session found `now_ms`'s comment on `post`, put there by the design model's script (tip
|
||||
T29). It saw the contradiction and would not guess, which was right, but it deliberated instead of
|
||||
stopping.
|
||||
|
||||
### M4a, task 16 — reviewed 2026-09-24 by the design model (Claude)
|
||||
|
||||
Accepted; finding 2 is closed. `read_checked` follows the task step for step: the path is checked
|
||||
without following links, the opened file must be the same one (device and inode), the owner and
|
||||
mode checks run on the open file, and the bytes come from that handle into `Zeroizing`. The gate
|
||||
passes; both of the plan's mutations (no inode check; the mode taken from the path) turn the
|
||||
given test red against this code. Against the owner's server, a token in a 0600 file gave the
|
||||
warning and connected, and the same file at 0644 was refused with its pointer; the token was in no
|
||||
output.
|
||||
|
||||
The one deviation, reported, was the plan's fault: step 6 of the comment said "from the old
|
||||
`check_file`", which made step 5's `grep` print a line the task said it would not. The given test
|
||||
also differed from the plan's copy, in rustfmt's layout only: the design model copied it into the
|
||||
plan before formatting it. The plan's copy is now the formatted file.
|
||||
|
||||
@@ -34,19 +34,24 @@ Written: `MAX_MESSAGE`, the opcode constants, `Incoming` (`Text`, `Ping`, `Pong`
|
||||
`Close(Option<u16>, String)`), `Decoder { buf, partial }` with `new` and `feed`, and the private
|
||||
`Header { fin, opcode, header_len, payload_len }`.
|
||||
|
||||
To fill, in this order, each with its steps above it:
|
||||
Also written, because they are the glue: **`Decoder::next_message`** (take a whole frame out of
|
||||
`buf` and act on it) and **`Decoder::header`** (read the first two bytes, then call the checks and
|
||||
`length` below). Read both first: they call everything you write.
|
||||
|
||||
1. `Decoder::header(&self) -> Result<Option<Header>, WsError>`: the next frame's header once all of
|
||||
it is in `buf`, checked against every rule above that does not need the payload. `Ok(None)`
|
||||
means "wait for more bytes". It only reads `buf`; it removes nothing.
|
||||
2. `close(payload) -> Result<Incoming, WsError>`.
|
||||
3. `Decoder::next_message(&mut self)`: uses `header`, and when the whole frame is in `buf`, takes it
|
||||
out (`drain`) and acts on it. Control frames may come between the frames of a text message and
|
||||
are returned at once.
|
||||
4. `encode(opcode, payload, mask) -> Vec<u8>`.
|
||||
To fill, **one at a time, in this order**, each with its steps above it. Each is a few lines:
|
||||
|
||||
No indexing that can go out of bounds, no `as` casts: read lengths with `get(2..4)` and
|
||||
`u16::from_be_bytes`, and convert with `usize::try_from` / `u8::try_from`.
|
||||
1. `check_first_bytes(b0, b1)`: the reserved bits, the mask bit, the opcode.
|
||||
2. `check_control(fin, payload_len)`.
|
||||
3. `Decoder::length(&self, short)`: the three length forms. `Ok(None)` means "wait for more bytes".
|
||||
4. `Decoder::check_data(&self, opcode, payload_len)`: the rules that depend on `partial`.
|
||||
5. `Decoder::data_frame(&mut self, opcode, fin, payload)`: a text or continuation payload.
|
||||
6. `close(payload)`.
|
||||
7. `encode(opcode, payload, mask)`.
|
||||
|
||||
The comment above each `todo!()` gives the constructs to use, down to the expressions for the
|
||||
indexing and the conversions. Use them as written: they are checked against the tests, and the
|
||||
indexing in them cannot go out of bounds. Do not weigh other ways to write the same thing. Write
|
||||
the function, run `cargo check -p gatewayd`, go on to the next.
|
||||
|
||||
## Steps
|
||||
|
||||
@@ -55,7 +60,7 @@ No indexing that can go out of bounds, no `as` casts: read lengths with `get(2..
|
||||
Add `pub mod frame;` to `crates/gatewayd/src/ws/mod.rs`.
|
||||
- [ ] **2. See it fail.** `cargo test -p gatewayd --test ws_frame`. Expected: it compiles and 7
|
||||
tests fail.
|
||||
- [ ] **3. Fill the four functions**, `cargo check -p gatewayd` after each.
|
||||
- [ ] **3. Fill the seven functions** in the order above, `cargo check -p gatewayd` after each.
|
||||
- [ ] **4. See it pass.** `cargo test -p gatewayd --test ws_frame`. Expected: 7 passed. The last
|
||||
test feeds 300 random streams, half of them with bits flipped, in random pieces, and compares
|
||||
your decoder with a simple one written inside the test: they must return the same messages, and
|
||||
|
||||
@@ -32,18 +32,25 @@ impl Ws {
|
||||
mut random: Box<dyn Read + Send>) -> Result<Ws, WsError>;
|
||||
fn send(&mut self, opcode: u8, payload: &[u8]) -> Result<(), WsError>;
|
||||
pub fn send_text(&mut self, text: &str) -> Result<(), WsError>;
|
||||
pub fn poll(&mut self, wait: Duration) -> Result<Option<String>, WsError>;
|
||||
pub fn poll(&mut self, wait: Duration) -> Result<Option<String>, WsError>; // written
|
||||
fn take_messages(&mut self) -> Result<Option<String>, WsError>;
|
||||
fn keep_alive(&mut self, now: Instant) -> Result<(), WsError>;
|
||||
fn read_timeout(&self, until: Instant, now: Instant) -> Duration;
|
||||
fn read_some(&mut self, timeout: Duration) -> Result<(), WsError>;
|
||||
pub fn close(mut self);
|
||||
}
|
||||
pub fn host_header(server: &ServerUrl) -> String;
|
||||
```
|
||||
|
||||
All are `todo!()`, each with its steps above it. `poll` is the one with the most in it: it returns
|
||||
**`poll` is written for you**: it is the glue, and it calls the four helpers after it. It returns
|
||||
the next text message, or `None` after about `wait` with none, and does the pinging, the pong
|
||||
answers and the dead-peer check on the way. Its read timeout is always the time to the **next
|
||||
thing it must do** (the end of `wait`, the next ping, or the dead-after limit), so a quiet
|
||||
connection neither spins nor oversleeps. Write it as its comment says, step by step; a helper
|
||||
function for step 1 is fine.
|
||||
connection neither spins nor oversleeps. Read it first.
|
||||
|
||||
Everything else is `todo!()`, each a few lines, with the steps and the expressions to use above it.
|
||||
Use them as written; do not weigh other ways to write the same thing. Write the function, run
|
||||
`cargo check -p gatewayd`, go on to the next.
|
||||
|
||||
`random` gives the handshake key and a fresh 4-byte mask for every frame we send (in `gatewayd`,
|
||||
`/dev/urandom`).
|
||||
@@ -55,8 +62,8 @@ function for step 1 is fine.
|
||||
Add `pub mod conn;` to `crates/gatewayd/src/ws/mod.rs`.
|
||||
- [ ] **2. See it fail.** `cargo test -p gatewayd --test ws_conn`. Expected: it compiles and 10
|
||||
tests fail.
|
||||
- [ ] **3. Fill `host_header`, `send`, `send_text`, `open`, `close`, then `poll`**,
|
||||
`cargo check -p gatewayd` after each.
|
||||
- [ ] **3. Fill `host_header`, `send`, `send_text`, `open`, `close`, then `take_messages`,
|
||||
`keep_alive`, `read_timeout`, `read_some`**, `cargo check -p gatewayd` after each.
|
||||
- [ ] **4. See it pass.** `cargo test -p gatewayd --test ws_conn`, five times. Expected: 10 passed
|
||||
each time, in under a second.
|
||||
- [ ] **5. Run the gate.** `cargo fmt --all`, then `make gate`. Expected last line: `gate: ok`.
|
||||
|
||||
@@ -33,21 +33,54 @@ The loop that ties tasks 03 to 13 together (spec sections 8 and 9):
|
||||
skeletons `crates/gatewayd/src/serve/mod.rs` and `crates/gatewayd/src/serve/handle.rs`
|
||||
- Modify: `crates/gatewayd/src/lib.rs` (`pub mod serve;`), `docs/implementer-log.md`
|
||||
|
||||
## Before anything else
|
||||
|
||||
Your **first two actions** are steps 1 and 2 below: copy the files, and see the tests fail. Do not
|
||||
read the other modules of `gatewayd` or `proto` first. Everything the functions you write call is
|
||||
in the table below, with its signature, and the comment above each `todo!()` gives the code to
|
||||
write, down to the expressions. Write each function as its comment says, run
|
||||
`cargo check -p gatewayd`, and go on to the next. Do not weigh other ways to write it.
|
||||
|
||||
## The skeletons
|
||||
|
||||
`serve/mod.rs`, written: the pointers, `INTERRUPTED`, `Log`, `Stop` (`Auth`, `State`, `Start`,
|
||||
`Asked`) and its `Display`, `Tuning` and its default, the `Gateway` struct, and the two functions
|
||||
that are glue: **`run`** (the connect-or-back-off loop) and **`Gateway::event_loop`** (finish turns,
|
||||
send typing, wait for an event, handle it). Read both first: they call everything you write.
|
||||
To fill: `From<StateError> for Stop`, `backoff`, `sleep_unless`, `connect`, `Gateway::new`,
|
||||
`Gateway::connected`.
|
||||
`Asked`) and its `Display`, `Tuning` and its default, the `Gateway` struct, and the glue: **`run`**
|
||||
(the connect-or-back-off loop), **`connect`** (`users/me`, the WebSocket, the wait for `hello`),
|
||||
**`Gateway::new`** and **`Gateway::event_loop`** (finish turns, send typing, wait for an event,
|
||||
handle it). To fill: `From<StateError> for Stop`, `backoff`, `sleep_unless`, `Gateway::connected`.
|
||||
|
||||
`serve/handle.rs`, all to fill: `now_ms`, `post`, `tracked`, `handle_post`, `start`, `finished`,
|
||||
`typing`, `catch_up`.
|
||||
`serve/handle.rs`, written: the glue **`catch_up`** (which channels). To fill: `now_ms`, `post`,
|
||||
`tracked`, `finished`, `typing`, `start`, `handle_post`, `catch_up_channel`.
|
||||
|
||||
Each `todo!()` has its steps above it, with the exact log lines. `run` takes the token already
|
||||
loaded and a `stop` flag, so the tests need no secrets and can end it; task 15's `main` passes a
|
||||
flag that is never set.
|
||||
Twelve functions, none more than about twenty lines. `run` takes the token already loaded and a
|
||||
`stop` flag, so the tests need no secrets and can end it; task 15's `main` passes a flag that is
|
||||
never set.
|
||||
|
||||
## What the functions call
|
||||
|
||||
| Call | Signature (from the earlier tasks) |
|
||||
|---|---|
|
||||
| `self.client.create_post` | `(&self, channel: &str, root: &str, message: &str) -> Result<Post, MmError>` |
|
||||
| `self.client.posts_since` | `(&self, channel: &str, since: i64) -> Result<Since, MmError>`; `Since { posts: Vec<Post>, full: bool }` |
|
||||
| `self.state.seen` / `knows_thread` | `(&self, id: &str) -> bool` |
|
||||
| `self.state.handled` | `(&mut self, post_id: &str, channel: &str, create_at: i64) -> Result<(), StateError>` |
|
||||
| `self.state.since` | `(&self, channel: &str) -> Option<i64>` |
|
||||
| `self.state.mark` | `(&mut self, channel: &str, at: i64) -> Result<(), StateError>` |
|
||||
| `self.state.join_thread` | `(&mut self, root: &str) -> Result<(), StateError>` |
|
||||
| `self.state.start_turn` | `(&mut self, turn: InFlight) -> Result<(), StateError>`; `InFlight { session, channel, root }`, all `String` |
|
||||
| `self.state.end_turn` | `(&mut self, session: &str) -> Result<(), StateError>` |
|
||||
| `self.state.take_in_flight` | `(&mut self) -> Result<Vec<InFlight>, StateError>` |
|
||||
| `self.router.route` | `(&self, post: &Post, channel_type: &str, known: &dyn Fn(&str) -> bool) -> Route` |
|
||||
| `Router::new` | `(me_id: &str, me_name: &str, users: &[String], channels: &[String]) -> Router` |
|
||||
| `self.queues.push` | `(&mut self, message: Message) -> Pushed` |
|
||||
| `self.queues.finish` | `(&mut self, session: &SessionId) -> Option<Batch>` |
|
||||
| `self.queues.threads` | `(&self) -> Vec<Thread>`; `Thread { channel, root }` |
|
||||
| `deliver` | `(poster: &dyn Poster, socket: &Path, batch: &Batch, log: &dyn Fn(&str))`; `Client` is a `Poster` |
|
||||
| `typing` | `(seq: u64, channel: &str, parent: &str) -> String` |
|
||||
| `ws.send_text` | `(&mut self, text: &str) -> Result<(), WsError>` |
|
||||
| `self.log` | `Arc<dyn Fn(&str) + Send + Sync>`: call it as `(self.log)(&line)` |
|
||||
|
||||
`?` turns a `StateError` into a `Stop` through the `From` you write first.
|
||||
|
||||
## Steps
|
||||
|
||||
@@ -58,11 +91,11 @@ flag that is never set.
|
||||
`cp docs/plans/M4a/files/crates/gatewayd/tests/support/fake_mm.rs docs/plans/M4a/files/crates/gatewayd/tests/support/gateway.rs crates/gatewayd/tests/support/`.
|
||||
Add `pub mod serve;` to `lib.rs`.
|
||||
- [ ] **2. See it fail.** `cargo test -p gatewayd --no-fail-fast --test serve --test serve_restart`.
|
||||
Expected: it compiles; `serve` 6 fail; `serve_restart` 6 fail and 1 passes (a damaged state file
|
||||
stops `run` before anything you write is called).
|
||||
- [ ] **3. Fill `mod.rs` first** (`from`, `backoff`, `sleep_unless`, `Gateway::new`, `connect`,
|
||||
`connected`), **then `handle.rs`** (`now_ms`, `post`, `tracked`, `finished`, `typing`, `start`,
|
||||
`handle_post`, `catch_up`). `cargo check -p gatewayd` after each function.
|
||||
Expected: it compiles; `serve` 6 fail; `serve_restart` 5 fail and 2 pass (a damaged state file
|
||||
and a refused token stop `run` before anything you write is called).
|
||||
- [ ] **3. Fill `mod.rs` first** (`from`, `backoff`, `sleep_unless`, `connected`), **then
|
||||
`handle.rs`** (`now_ms`, `post`, `tracked`, `finished`, `typing`, `start`, `handle_post`,
|
||||
`catch_up_channel`). `cargo check -p gatewayd` after each function.
|
||||
- [ ] **4. See it pass.** `cargo test -p gatewayd --test serve --test serve_restart`, five times.
|
||||
Expected: 6 and 7 passed each time, in about 2 s.
|
||||
- [ ] **5. Run the gate.** `cargo fmt --all`, then `make gate`. Expected last line: `gate: ok`.
|
||||
@@ -74,6 +107,9 @@ flag that is never set.
|
||||
|
||||
## Stop and report if
|
||||
|
||||
- A comment above a `todo!()` does not fit its function, or contradicts a test. The comments were
|
||||
written by hand and one was wrong before; stop at once and quote it, rather than guess.
|
||||
- A test passes only sometimes, or a test takes 5 s or more (that is a wait that timed out, not a
|
||||
pass).
|
||||
- `run` or `event_loop` seems to need a change. They are given; report instead.
|
||||
- A written function (`run`, `connect`, `Gateway::new`, `event_loop`, `catch_up`) seems to need a
|
||||
change. They are given; report instead.
|
||||
|
||||
@@ -54,4 +54,5 @@ This is the last task of M4a. Stop after the commit.
|
||||
|
||||
## Stop and report if
|
||||
|
||||
- The comment above the `todo!()` does not fit the function, or contradicts a test: quote it.
|
||||
- A test's output contains the token.
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
# M4a task 16: read a secret file from the file that was checked
|
||||
|
||||
**Branch:** `m4a` (run `git switch m4a`; `git status --short` must be empty, otherwise stop)
|
||||
**Commit subject:** `gatewayd: read a secret file from the file that was checked`
|
||||
|
||||
## Goal
|
||||
|
||||
**M4a review, finding 2.** `load` checks a secret file by its path (`check_file`: not a symbolic
|
||||
link, a regular file, owned by us, mode 0600 or 0400), then reads it **by the path again**
|
||||
(`std::fs::read(path)`). Between the two, someone who can write to the directory could replace the
|
||||
file with a link to another file, or with another file, and `gatewayd` would read that instead.
|
||||
|
||||
The fix, without any new dependency: check the path, open it, then check the **opened** file, and
|
||||
read from that same handle.
|
||||
|
||||
1. Check the path, as now: absolute; `symlink_metadata` (it does not follow links); not a link; a
|
||||
regular file. Keep that metadata as `named`.
|
||||
2. Call `between()` (see below).
|
||||
3. Open the path (`std::fs::File::open`).
|
||||
4. Take the open file's metadata (`file.metadata()`, as `opened`). If its device and inode are not
|
||||
the ones in `named`, the path changed between the check and the open: refuse. Then the owner
|
||||
and mode checks, on `opened`.
|
||||
5. Read the whole file from that handle into a `Zeroizing<Vec<u8>>`.
|
||||
|
||||
`between` exists only for the tests: it runs after step 1 and before step 3, and each test swaps
|
||||
the file there. `load` passes `&|| {}`.
|
||||
|
||||
## Files
|
||||
|
||||
- Copy: `crates/gatewayd/tests/secrets_race.rs`
|
||||
- Modify: `crates/gatewayd/src/secrets.rs`, `docs/implementer-log.md`
|
||||
|
||||
## Before anything else
|
||||
|
||||
Your **first two actions** are steps 1 and 2 below: copy the test, and see it fail. Everything you
|
||||
need is in this file; read only `crates/gatewayd/src/secrets.rs` besides it.
|
||||
|
||||
## The changes, exactly
|
||||
|
||||
**1. Replace `fn check_file` with `pub fn read_checked`.** Delete `check_file` and write in its
|
||||
place:
|
||||
|
||||
```rust
|
||||
/// The bytes of an owner-only regular file, read from the very file that was checked. `between`
|
||||
/// runs after the path is checked and before it is opened: `load` passes `&|| {}`; tests use it
|
||||
/// to swap the file.
|
||||
pub fn read_checked(path: &Path, between: &dyn Fn()) -> Result<Zeroizing<Vec<u8>>, String> {
|
||||
// 1. `path.is_absolute()`, else "<path> is not an absolute path".
|
||||
// 2. `let named = std::fs::symlink_metadata(path)`, an Err(e) is "cannot read <path>: <e>";
|
||||
// `named.file_type().is_symlink()` is "<path> is a symbolic link";
|
||||
// `!named.file_type().is_file()` is "<path> is not a regular file".
|
||||
// 3. `between();`
|
||||
// 4. `let mut file = std::fs::File::open(path)`, an Err(e) is "cannot read <path>: <e>";
|
||||
// `let opened = file.metadata()`, the same error text.
|
||||
// 5. `if (opened.dev(), opened.ino()) != (named.dev(), named.ino())`:
|
||||
// "<path> changed while it was read".
|
||||
// 6. The owner and mode checks from the old `check_file`, word for word, on `opened` (not on
|
||||
// `named`): the uid of "/proc/self"; "<path> is not owned by the user gatewayd runs as";
|
||||
// "<path> has mode <mode:03o>; only the owner may read it (0600 or 0400)".
|
||||
// 7. `let mut bytes = Zeroizing::new(Vec::new());` then `file.read_to_end(&mut bytes)`, an
|
||||
// Err(e) is "cannot read <path>: <e>". Ok(bytes).
|
||||
todo!()
|
||||
}
|
||||
```
|
||||
|
||||
Every message is built with `path.display()`, as in the old `check_file`. `dev`, `ino`, `uid` and
|
||||
`mode` come from `std::os::unix::fs::MetadataExt`, which the file already imports. Add
|
||||
`use std::io::Read;` for `read_to_end`.
|
||||
|
||||
**2. In `load`, the `SecretSource::File(path)` branch.** Replace the `check_file` call and the
|
||||
`std::fs::read(path)` after it with:
|
||||
|
||||
```rust
|
||||
let mut bytes = match read_checked(path, &|| {}) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(why) => {
|
||||
return Err(SecretError {
|
||||
name: name.to_string(),
|
||||
why,
|
||||
});
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
and the `value(bytes)` call in that branch becomes `value(std::mem::take(&mut *bytes))`, which
|
||||
moves the bytes out and leaves nothing unwiped behind. The warning and the other two branches do
|
||||
not change.
|
||||
|
||||
## Steps
|
||||
|
||||
- [ ] **1. Copy.** `git switch m4a`, then
|
||||
`cp docs/plans/M4a/files/crates/gatewayd/tests/secrets_race.rs crates/gatewayd/tests/`
|
||||
- [ ] **2. See it fail.** `cargo test -p gatewayd --test secrets_race`. Expected: it does not
|
||||
compile (`read_checked` does not exist).
|
||||
- [ ] **3. Make the two changes.** Write `read_checked` step by step as its comment says,
|
||||
`cargo check -p gatewayd` after it, then change `load`. Run `cargo fmt --all`.
|
||||
- [ ] **4. See it pass.** `cargo test -p gatewayd --test secrets_race --test secrets --test main`.
|
||||
Expected: 5, 8 and 5 passed.
|
||||
- [ ] **5. Check.** `grep -n "check_file\|std::fs::read(" crates/gatewayd/src/secrets.rs` prints
|
||||
only the credential branch's `std::fs::read(&path)`.
|
||||
- [ ] **6. Run the gate.** `make gate`. Expected last line: `gate: ok`, with about 769 tests.
|
||||
- [ ] **7. Log and commit.** `git add crates/gatewayd docs/implementer-log.md Cargo.lock && git commit`
|
||||
|
||||
This is the last follow-up task of M4a. Stop after the commit.
|
||||
|
||||
## Done when
|
||||
|
||||
- The three suites pass; `make gate` prints `gate: ok`.
|
||||
|
||||
## Stop and report if
|
||||
|
||||
- A comment above the `todo!()` does not fit, or contradicts a test: quote it.
|
||||
- A test seems to need `unsafe`, `libc` or any new dependency.
|
||||
@@ -62,8 +62,47 @@ checked to compile against its task's tests and fail them.
|
||||
| 13 | `13-gatewayd-deliver.md` | turns on `loop.sock`, answers in the thread | `deliver.rs`, `support/fake_loop.rs` | reference; 11 mutations, 10 caught; the one left accepts an event frame with another id, which `loopd` never sends |
|
||||
| 14 | `14-gatewayd-serve.md` | the serve loop: routing, typing, catch-up, reconnecting | `serve.rs`, `serve_restart.rs`, `support/fake_mm.rs`, `support/gateway.rs` | reference; 14 mutations, all caught once four tests were added; 8 runs clean |
|
||||
| 15 | `15-gatewayd-main.md` | `gatewayd serve --config <path>` | `main.rs` | reference; run against the owner's server, token never printed |
|
||||
| 16 | `16-gatewayd-secret-file-race.md` | review finding 2: a secret file is read from the file that was checked | `secrets_race.rs` | reference in the working tree, then removed; 2 mutations, both caught; gate at the end state |
|
||||
|
||||
At the end of task 15: about 762 tests (650 before task 01).
|
||||
At the end of task 15: about 762 tests (650 before task 01). The design model's review fixes add 2; at the end of task 16: about 769.
|
||||
|
||||
## Changes during the run
|
||||
|
||||
- 2026-09-23, task 08: tasks 01 to 07 committed; task 08's session ended with nothing written. It
|
||||
planned all four `todo!()`s in its head, most of it deliberating how to avoid indexing, and was
|
||||
cut off (tip T25, again: `Decoder::header` has a dozen branches and was one `todo!()`). The
|
||||
tree also had `handshake.rs` with its imports in 2021-edition order, which no logged command
|
||||
explains; it was reverted. The attempt is saved in `.state/runs/M4a/08-first-attempt.diff`.
|
||||
The design model split the skeleton: `next_message` and `header` are now written as glue, over
|
||||
seven helpers of a few lines each whose comments give the exact expressions. The same was done
|
||||
for task 09's `poll` (glue over four helpers) before it could stop the same way. Both new
|
||||
skeletons were checked in a scratch copy of the branch: they compile and fail their tests, and
|
||||
filled literally from their comments they pass (7 of 7, 10 of 10 five times), clippy clean, gate
|
||||
ok. Resume from task 08.
|
||||
- 2026-09-23, task 14: tasks 08 to 13 committed, each on its first session. Task 14's session
|
||||
wrote nothing, not even the copies: it read twenty source files to learn the APIs, then planned
|
||||
`connect`'s wait for `hello` in prose ("let me stop designing", and it did not) until it was cut
|
||||
off. Twelve-plus `todo!()`s across two files and the whole crate's API were too much for one
|
||||
turn. The design model wrote `connect`, `Gateway::new` and `catch_up` as glue (leaving
|
||||
`catch_up_channel`), gave `handle_post` and `start` their borrow- and move-sensitive lines
|
||||
verbatim, added a table of every call the helpers make with its signature (checked against the
|
||||
branch), and made copying and seeing the tests fail the first two actions. Task 15's `serve`
|
||||
comment, whose numbered steps had run together, was rewritten one step per item. Both were
|
||||
checked in a scratch copy of the branch at task 14's start: the skeletons compile and fail
|
||||
(`serve` 6, `serve_restart` 5 with 2 passing, `main` 4 with 1 passing), and filled from their
|
||||
comments they pass (five runs), clippy clean, gate ok. Resume from task 14.
|
||||
- 2026-09-24, task 14 again: this session copied the files and saw the tests fail, as told, then
|
||||
deliberated until cut off, over a real contradiction: `post`'s comment was `now_ms`'s text.
|
||||
The design model's script that rewrote the comments placed each after the first
|
||||
eight-space comment following the function's name; `now_ms` is a free function with a
|
||||
four-space comment, so its text went to `post`, and `post`'s was lost. The check filled the
|
||||
bodies from the design model's own code, not from the comments, so it could not see this.
|
||||
Both comments are fixed; every `todo!()` comment in tasks 14 and 15 was then read beside its
|
||||
signature. Tasks 14 and 15 now say to stop and quote a comment that does not fit. The attempt is
|
||||
saved in `.state/runs/M4a/14-second-attempt*`. Resume from task 14.
|
||||
|
||||
- 2026-09-24, after the review: the design model fixed findings 1 and 3 (commit `0081f24`), and
|
||||
wrote task 16 for finding 2. Run it with `tools/run-plan.sh docs/plans/M4a 16`.
|
||||
|
||||
## Running it
|
||||
|
||||
|
||||
@@ -25,13 +25,20 @@ fn main() -> ExitCode {
|
||||
}
|
||||
|
||||
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.
|
||||
// Each failure prints its message with `eprintln!` and returns `ExitCode::from(1)`.
|
||||
// 1. `let config = match Config::load(path) { ... }`: an Err(e) prints
|
||||
// "gatewayd: {e}\n{START_FAILED}".
|
||||
// 2. `let source = match config.token_source() { ... }`: an Err(why) prints
|
||||
// "gatewayd: {}: {why}\n{START_FAILED}" with `path.display()`.
|
||||
// 3. `let loaded = match secrets::load(MATTERMOST_TOKEN, &source, &|k| std::env::var_os(k))`:
|
||||
// an Err(e) prints "gatewayd: {e}" (the error carries its own pointer).
|
||||
// Then `if let Some(warning) = &loaded.warning { eprintln!("{warning}"); }`.
|
||||
// 4. The state file's directory:
|
||||
// `let dir = config.state_path().parent().map(Path::to_path_buf).unwrap_or_default();`
|
||||
// `std::fs::DirBuilder::new().recursive(true).mode(0o700).create(&dir)`: an Err(e) prints
|
||||
// "gatewayd: cannot prepare {}: {e}\n{START_FAILED}" with `dir.display()`.
|
||||
// 5. `let log: gatewayd::serve::Log = Arc::new(|line: &str| eprintln!("{line}"));`
|
||||
// `let stop = run(config, loaded.secret, Tuning::default(), log, &AtomicBool::new(false));`
|
||||
// `eprintln!("{stop}");` and `ExitCode::from(1)`.
|
||||
todo!()
|
||||
}
|
||||
|
||||
@@ -14,65 +14,110 @@ 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).
|
||||
// `let ms = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_millis()).unwrap_or(0);`
|
||||
// then `i64::try_from(ms).unwrap_or(i64::MAX)`.
|
||||
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>".
|
||||
// `if let Err(e) = self.client.create_post(channel, root, text)`: log
|
||||
// "gatewayd: cannot post in <channel> (thread <root>): <e>", with `(self.log)(&line)`.
|
||||
// Nothing else: posting records nothing in the state.
|
||||
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.
|
||||
// `channel_type == "D" || self.config.allow.channels.iter().any(|c| c == channel)`.
|
||||
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.
|
||||
// 1. `if !self.tracked(&post.channel_id, channel_type) || self.state.seen(&post.id)`:
|
||||
// return Ok(()).
|
||||
// 2. `self.state.handled(&post.id, &post.channel_id, post.create_at)?;` before anything
|
||||
// else: after a crash, a post is never answered twice.
|
||||
// 3. Route it, with the state answering "is this thread ours?":
|
||||
// `let state = &self.state;`
|
||||
// `let route = self.router.route(post, channel_type, &|root| state.knows_thread(root));`
|
||||
// then `match route`:
|
||||
// - Route::Ignore(Ignored::NotAllowed): log
|
||||
// "gatewayd: ignored post <post.id> from <post.user_id>: not allowed"
|
||||
// (never the text).
|
||||
// - Route::Ignore(_): nothing.
|
||||
// - Route::Reply { thread, text }: `self.post(&thread.channel, &thread.root, &text)`.
|
||||
// - Route::Queue(message): if `message.joins_thread`,
|
||||
// `self.state.join_thread(&message.thread.root)?`; then
|
||||
// `match self.queues.push(message)`:
|
||||
// Pushed::Start(batch) -> `self.start(batch)?`; Pushed::Waiting -> nothing;
|
||||
// Pushed::Full(thread) -> `self.post(&thread.channel, &thread.root, BUSY)`.
|
||||
// 4. Ok(()).
|
||||
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.
|
||||
// 1. `self.state.start_turn(InFlight { session: batch.session.as_str().to_string(),
|
||||
// channel: batch.thread.channel.clone(), root: batch.thread.root.clone() })?;`
|
||||
// 2. Clone what the thread takes, before the `move`:
|
||||
// `let (client, socket, log, done) = (Arc::clone(&self.client),
|
||||
// self.loop_socket.clone(), Arc::clone(&self.log), self.done_tx.clone());`
|
||||
// `let (session, thread) = (batch.session.clone(), batch.thread.clone());`
|
||||
// 3. `let spawned = std::thread::Builder::new().spawn(move || {
|
||||
// deliver(client.as_ref(), &socket, &batch, &|line| log(line));
|
||||
// let _ = done.send(batch.session); });`
|
||||
// 4. `if let Err(e) = spawned`: log "gatewayd: cannot start a thread for <session>: <e>"
|
||||
// (`session.as_str()`), `self.post(&thread.channel, &thread.root, LOOP_DOWN)`, and
|
||||
// `let _ = self.done_tx.send(session);` so the session does not stay busy.
|
||||
// 5. Ok(()).
|
||||
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.
|
||||
// `while let Ok(session) = self.done_rx.try_recv() {` (never blocks)
|
||||
// `self.state.end_turn(session.as_str())?;`
|
||||
// `if let Some(batch) = self.queues.finish(&session) { self.start(batch)?; }` `}`
|
||||
// Then Ok(()).
|
||||
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.
|
||||
// `for thread in self.queues.threads() { self.seq += 1;
|
||||
// ws.send_text(&typing(self.seq, &thread.channel, &thread.root))?; }` then Ok(()).
|
||||
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.
|
||||
/// and each allowed channel. Written for you: it is the glue.
|
||||
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.
|
||||
let mut channels = Vec::new();
|
||||
for user in self.config.allow.users.clone() {
|
||||
match self.client.direct_channel(&self.me.id, &user) {
|
||||
Ok(id) => channels.push((id, "D")),
|
||||
Err(e) => (self.log)(&format!("gatewayd: no direct channel with {user}: {e}")),
|
||||
}
|
||||
}
|
||||
channels.extend(self.config.allow.channels.iter().map(|c| (c.clone(), "O")));
|
||||
for (channel, channel_type) in channels {
|
||||
self.catch_up_channel(&channel, channel_type)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// One channel's posts since its mark. A channel seen for the first time starts from now.
|
||||
fn catch_up_channel(&mut self, channel: &str, channel_type: &str) -> Result<(), Stop> {
|
||||
// 1. `let Some(since) = self.state.since(channel) else { ... }`: with no mark,
|
||||
// `self.state.mark(channel, now_ms())?` and return Ok(()).
|
||||
// 2. `self.client.posts_since(channel, since)`: an Err(e) is logged
|
||||
// "gatewayd: cannot catch up <channel>: <e>" and returns Ok(()).
|
||||
// 3. When `found.full`, log "gatewayd: <channel>: too many posts to catch up; some may be
|
||||
// missed".
|
||||
// 4. `for post in &found.posts { self.handle_post(post, channel_type)?; }` and Ok(()).
|
||||
todo!()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ impl std::fmt::Display for Stop {
|
||||
|
||||
impl From<StateError> for Stop {
|
||||
fn from(e: StateError) -> Stop {
|
||||
// Stop::State(e).
|
||||
// `Stop::State(e)`.
|
||||
todo!()
|
||||
}
|
||||
}
|
||||
@@ -107,14 +107,16 @@ pub(crate) struct Gateway {
|
||||
|
||||
/// 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.
|
||||
// `let last = tuning.backoff.last().copied().unwrap_or(Duration::from_secs(30));` then
|
||||
// `tuning.backoff.get(n).copied().unwrap_or(last)`.
|
||||
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.
|
||||
// `let until = Instant::now() + d;` then, while `!stop.load(Ordering::SeqCst)` and
|
||||
// `Instant::now() < until`, sleep
|
||||
// `Duration::from_millis(20).min(until.saturating_duration_since(Instant::now()))`.
|
||||
todo!()
|
||||
}
|
||||
|
||||
@@ -167,28 +169,69 @@ pub fn run(config: Config, token: Secret, tuning: Tuning, log: Log, stop: &Atomi
|
||||
|
||||
/// 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!()
|
||||
let me = client.me()?;
|
||||
let timing = Timing {
|
||||
ping_every: Duration::from_millis(config.limits.ping_every_ms),
|
||||
dead_after: Duration::from_millis(config.limits.dead_after_ms),
|
||||
};
|
||||
let random =
|
||||
File::open("/dev/urandom").map_err(|e| MmError::Net(format!("/dev/urandom: {e}")))?;
|
||||
let net = |e: String| MmError::Net(format!("websocket: {e}"));
|
||||
let mut ws = Ws::open(
|
||||
client.connector(),
|
||||
client.token().expose(),
|
||||
timing,
|
||||
Box::new(random),
|
||||
)
|
||||
.map_err(|e| net(e.to_string()))?;
|
||||
// The server says hello first; anything before it is skipped, and silence is an error.
|
||||
let until = Instant::now() + timing.dead_after;
|
||||
while Instant::now() < until {
|
||||
if let Some(text) = ws.poll(tuning.poll).map_err(|e| net(e.to_string()))?
|
||||
&& matches!(parse_event(&text), Ok(Event::Hello))
|
||||
{
|
||||
return Ok((me, ws));
|
||||
}
|
||||
}
|
||||
Err(net("no hello from the server".to_string()))
|
||||
}
|
||||
|
||||
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!()
|
||||
let (done_tx, done_rx) = mpsc::channel();
|
||||
let limit = usize::try_from(config.limits.queue).unwrap_or(usize::MAX);
|
||||
Gateway {
|
||||
// Both are set on each connection, from `users/me`.
|
||||
router: Router::new("", "", &[], &[]),
|
||||
me: Me {
|
||||
id: String::new(),
|
||||
username: String::new(),
|
||||
},
|
||||
loop_socket: config.loop_socket(),
|
||||
queues: Queues::new(limit),
|
||||
config,
|
||||
client,
|
||||
state,
|
||||
done_tx,
|
||||
done_rx,
|
||||
log,
|
||||
seq: 0,
|
||||
restarted: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
// 1. Log exactly "gatewayd: connected to <url> as <username>"
|
||||
// (`self.config.mattermost.url`, `me.username`), with `(self.log)(&line)`.
|
||||
// 2. `let allow = &self.config.allow;`
|
||||
// `self.router = Router::new(&me.id, &me.username, &allow.users, &allow.channels);`
|
||||
// then `self.me = me;`.
|
||||
// 3. The first time only (`if !self.restarted { self.restarted = true; ... }`): for each
|
||||
// `turn` in `self.state.take_in_flight()?`, `self.post(&turn.channel, &turn.root,
|
||||
// INTERRUPTED)`. A later reconnect must not: those turns are still running.
|
||||
// 4. Ok(()).
|
||||
todo!()
|
||||
}
|
||||
|
||||
|
||||
@@ -58,19 +58,65 @@ impl Ws {
|
||||
|
||||
/// 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`.
|
||||
/// dead-after limit is `Dead`. Written for you: it is the glue.
|
||||
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.
|
||||
let until = Instant::now() + wait;
|
||||
loop {
|
||||
if let Some(text) = self.take_messages()? {
|
||||
return Ok(Some(text));
|
||||
}
|
||||
let now = Instant::now();
|
||||
self.keep_alive(now)?;
|
||||
if now >= until {
|
||||
return Ok(None);
|
||||
}
|
||||
let timeout = self.read_timeout(until, now);
|
||||
self.read_some(timeout)?;
|
||||
}
|
||||
}
|
||||
|
||||
/// Act on every whole message the decoder holds, until one is text.
|
||||
fn take_messages(&mut self) -> Result<Option<String>, WsError> {
|
||||
// `while let Some(message) = self.decoder.next_message()? { match message { ... } }`:
|
||||
// - Text(text) -> return Ok(Some(text)).
|
||||
// - Ping(payload) -> `self.send(PONG, &payload)?`.
|
||||
// - Pong(_) -> nothing.
|
||||
// - Close(code, _) -> the reply is the code as 2 big-endian bytes, or empty:
|
||||
// `code.map(|c| c.to_be_bytes().to_vec()).unwrap_or_default()`. Then
|
||||
// `let _ = self.send(CLOSE, &reply);` (the peer may be gone) and Err(WsError::Closed).
|
||||
// After the loop: Ok(None).
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// The dead-peer check, and our ping when one is due.
|
||||
fn keep_alive(&mut self, now: Instant) -> Result<(), WsError> {
|
||||
// 1. `now.duration_since(self.last_heard) >= self.timing.dead_after` -> Err(WsError::Dead).
|
||||
// 2. `now.duration_since(self.last_ping) >= self.timing.ping_every` ->
|
||||
// `self.send(PING, b"")?` and `self.last_ping = now`.
|
||||
// 3. Ok(()).
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// How long the next read may wait: until the soonest of the end of `wait`, the next ping and
|
||||
/// the dead-after limit, and never less than 1 ms.
|
||||
fn read_timeout(&self, until: Instant, now: Instant) -> Duration {
|
||||
// `(self.last_ping + self.timing.ping_every).saturating_duration_since(now)` is the time to
|
||||
// the next ping; the same with last_heard and dead_after; and
|
||||
// `until.saturating_duration_since(now)`. The least of the three (`.min`), then
|
||||
// `.max(Duration::from_millis(1))`.
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// One read, at most `timeout` long, fed to the decoder.
|
||||
fn read_some(&mut self, timeout: Duration) -> Result<(), WsError> {
|
||||
// 1. `self.stream.set_read_timeout(Some(timeout))?`.
|
||||
// 2. `let mut buf = [0u8; 16 * 1024];` and `match self.stream.read(&mut buf)`:
|
||||
// - Ok(0) -> Err(WsError::Closed).
|
||||
// - Ok(n) -> `self.decoder.feed(buf.get(..n).unwrap_or_default())`,
|
||||
// `self.last_heard = Instant::now()`, Ok(()).
|
||||
// - Err(e) whose `e.kind()` is WouldBlock, TimedOut or Interrupted -> Ok(()): nothing
|
||||
// came, and `poll` goes round.
|
||||
// - any other Err(e) -> Err(WsError::Io(e)).
|
||||
todo!()
|
||||
}
|
||||
|
||||
|
||||
@@ -50,42 +50,143 @@ impl Decoder {
|
||||
}
|
||||
|
||||
/// The next whole message, `None` if more bytes are needed, or the error that ends the
|
||||
/// connection. After an error, do not call again.
|
||||
/// connection. After an error, do not call again. Written for you: it is the glue.
|
||||
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!()
|
||||
loop {
|
||||
let Some(header) = self.header()? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let total = header.header_len.saturating_add(header.payload_len);
|
||||
if self.buf.len() < total {
|
||||
return Ok(None);
|
||||
}
|
||||
let payload: Vec<u8> = self
|
||||
.buf
|
||||
.get(header.header_len..total)
|
||||
.unwrap_or_default()
|
||||
.to_vec();
|
||||
self.buf.drain(..total);
|
||||
match header.opcode {
|
||||
PING => return Ok(Some(Incoming::Ping(payload))),
|
||||
PONG => return Ok(Some(Incoming::Pong(payload))),
|
||||
CLOSE => return close(&payload).map(Some),
|
||||
_ => {
|
||||
// A text or continuation frame: a whole message, or wait for the next frame.
|
||||
if let Some(text) = self.data_frame(header.opcode, header.fin, payload)? {
|
||||
return Ok(Some(Incoming::Text(text)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The next frame's header, once it is all here, checked against every rule that does not need
|
||||
/// the payload.
|
||||
/// The next frame's header, once all of it is in `buf`, checked against every rule that does
|
||||
/// not need the payload. It only reads `buf`. Written for you: it is the glue.
|
||||
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.
|
||||
let (Some(&b0), Some(&b1)) = (self.buf.first(), self.buf.get(1)) else {
|
||||
return Ok(None);
|
||||
};
|
||||
check_first_bytes(b0, b1)?;
|
||||
let fin = b0 & 0x80 != 0;
|
||||
let opcode = b0 & 0x0F;
|
||||
let Some((header_len, payload_len)) = self.length(b1 & 0x7F)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
if opcode & 0x8 != 0 {
|
||||
check_control(fin, payload_len)?;
|
||||
} else {
|
||||
self.check_data(opcode, payload_len)?;
|
||||
}
|
||||
Ok(Some(Header {
|
||||
fin,
|
||||
opcode,
|
||||
header_len,
|
||||
payload_len,
|
||||
}))
|
||||
}
|
||||
|
||||
/// The header's length and the payload's, from the 7-bit length `short` in byte 1 and the
|
||||
/// bytes after it. `Ok(None)` while those bytes have not all arrived.
|
||||
fn length(&self, short: u8) -> Result<Option<(usize, usize)>, WsError> {
|
||||
// Three forms, a `match short`:
|
||||
// - 0..=125: Ok(Some((2, usize::from(short)))).
|
||||
// - 126: `let Some(bytes) = self.buf.get(2..4) else { return Ok(None) };` then
|
||||
// `let len = u16::from_be_bytes([bytes[0], bytes[1]]);` (`bytes` has exactly 2 bytes, so
|
||||
// this indexing cannot fail). len < 126 is Protocol("a length not in its shortest
|
||||
// form"). Else Ok(Some((4, usize::from(len)))).
|
||||
// - 127: `let Some(bytes) = self.buf.get(2..10) else { return Ok(None) };`, then
|
||||
// `let mut b = [0u8; 8]; b.copy_from_slice(bytes); let len = u64::from_be_bytes(b);`.
|
||||
// len >> 63 != 0 is Protocol("a 64-bit length with its top bit set"); len <= 0xFFFF is
|
||||
// Protocol("a length not in its shortest form"). Else
|
||||
// Ok(Some((10, usize::try_from(len).unwrap_or(usize::MAX)))).
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// The rules for a text or continuation frame that need what came before.
|
||||
fn check_data(&self, opcode: u8, payload_len: usize) -> Result<(), WsError> {
|
||||
// 1. TEXT while `self.partial` is Some: Protocol("a new message inside an unfinished one").
|
||||
// 2. CONTINUATION while `self.partial` is None: Protocol("a continuation with nothing to
|
||||
// continue").
|
||||
// 3. `let so_far = self.partial.as_ref().map_or(0, Vec::len);` then
|
||||
// payload_len > MAX_MESSAGE.saturating_sub(so_far) is TooLarge.
|
||||
// 4. Ok(()).
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// A text or continuation frame's payload, taken out of `buf`: the whole message when `fin`
|
||||
/// is set, or `None` when more frames must come.
|
||||
fn data_frame(
|
||||
&mut self,
|
||||
opcode: u8,
|
||||
fin: bool,
|
||||
payload: Vec<u8>,
|
||||
) -> Result<Option<String>, WsError> {
|
||||
// 1. The message so far: `Vec::new()` for TEXT, `self.partial.take().unwrap_or_default()`
|
||||
// for CONTINUATION. Append `payload` to it.
|
||||
// 2. Not `fin`: `self.partial = Some(message);` and Ok(None).
|
||||
// 3. `fin`: `String::from_utf8(message)`; an error is Protocol("text is not UTF-8"); else
|
||||
// Ok(Some(text)).
|
||||
todo!()
|
||||
}
|
||||
}
|
||||
|
||||
/// The rules on the first two bytes alone.
|
||||
fn check_first_bytes(b0: u8, b1: u8) -> Result<(), WsError> {
|
||||
// In this order, each a Protocol error: b0 & 0x70 != 0 ("a reserved bit is set");
|
||||
// b1 & 0x80 != 0 ("a frame from the server is masked"); the opcode b0 & 0x0F is not one of
|
||||
// CONTINUATION, TEXT, CLOSE, PING, PONG ("opcode <n>"). Then Ok(()).
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// The rules for a control frame (close, ping, pong).
|
||||
fn check_control(fin: bool, payload_len: usize) -> Result<(), WsError> {
|
||||
// Not `fin`: Protocol("a fragmented control frame"). payload_len > 125: Protocol("a control
|
||||
// frame over 125 bytes"). Else Ok(()).
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// A close frame's payload.
|
||||
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).
|
||||
// A `match payload` on slice patterns, no indexing:
|
||||
// - `[]` -> Ok(Incoming::Close(None, String::new())).
|
||||
// - `[_]` -> Protocol("a close frame of one byte").
|
||||
// - `[a, b, reason @ ..]` -> the code is `u16::from_be_bytes([*a, *b])`; the reason is
|
||||
// `String::from_utf8(reason.to_vec())`, whose error is Protocol("a close reason that is not
|
||||
// UTF-8"). Ok(Incoming::Close(Some(code), reason)).
|
||||
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.
|
||||
// 1. `let mut out = vec![0x80 | (opcode & 0x0F)];` and `let len = payload.len();`.
|
||||
// 2. The length, with the mask bit 0x80 set in its first byte, in three branches:
|
||||
// `if len < 126`: push `0x80 | u8::try_from(len).unwrap_or(0)`.
|
||||
// `else if let Ok(len16) = u16::try_from(len)`: push `0x80 | 126`, then
|
||||
// `extend_from_slice(&len16.to_be_bytes())`.
|
||||
// `else`: push `0x80 | 127`, then
|
||||
// `extend_from_slice(&u64::try_from(len).unwrap_or(u64::MAX).to_be_bytes())`.
|
||||
// (`unwrap_or` never uses its fallback here; it is how to convert without a panic.)
|
||||
// 3. `out.extend_from_slice(&mask);`
|
||||
// 4. `out.extend(payload.iter().zip(mask.iter().cycle()).map(|(b, m)| b ^ m));` and return out.
|
||||
todo!()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
//! A secret file is read from the file that was checked, never from whatever the path names a
|
||||
//! moment later (M4a review, finding 2). `read_checked` runs `between` after checking the path and
|
||||
//! before opening it; each test swaps something there. Do not edit.
|
||||
|
||||
#[path = "support/tmp.rs"]
|
||||
mod tmp;
|
||||
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use gatewayd::secrets::read_checked;
|
||||
use tmp::TempDir;
|
||||
|
||||
const TOKEN: &str = "the-real-token";
|
||||
const OTHER: &str = "a-file-the-owner-never-chose";
|
||||
|
||||
fn owner_file(dir: &TempDir, name: &str, text: &str) -> PathBuf {
|
||||
let path = dir.write(name, text);
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
fn refused(path: &Path, between: &dyn Fn(), word: &str) {
|
||||
let why = read_checked(path, between).expect_err(word);
|
||||
assert!(why.contains(word), "{word}: {why}");
|
||||
assert!(why.contains(&path.display().to_string()), "{why}");
|
||||
assert!(
|
||||
!why.contains(TOKEN) && !why.contains(OTHER),
|
||||
"never a value: {why}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_untouched_file_is_read() {
|
||||
let dir = TempDir::new("race-ok");
|
||||
let path = owner_file(&dir, "token", &format!("{TOKEN}\n"));
|
||||
let bytes = read_checked(&path, &|| {}).unwrap();
|
||||
assert_eq!(bytes.as_slice(), format!("{TOKEN}\n").as_bytes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_swapped_for_a_link_is_refused() {
|
||||
let dir = TempDir::new("race-link");
|
||||
let path = owner_file(&dir, "token", TOKEN);
|
||||
let other = owner_file(&dir, "other", OTHER);
|
||||
let swap = || {
|
||||
std::fs::remove_file(&path).unwrap();
|
||||
std::os::unix::fs::symlink(&other, &path).unwrap();
|
||||
};
|
||||
refused(&path, &swap, "changed while it was read");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_swapped_for_another_file_is_refused() {
|
||||
let dir = TempDir::new("race-rename");
|
||||
let path = owner_file(&dir, "token", TOKEN);
|
||||
let other = owner_file(&dir, "other", OTHER);
|
||||
let swap = || std::fs::rename(&other, &path).unwrap();
|
||||
refused(&path, &swap, "changed while it was read");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_checks_hold_for_the_file_that_is_read() {
|
||||
let dir = TempDir::new("race-mode");
|
||||
let path = owner_file(&dir, "token", TOKEN);
|
||||
let widen = || {
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
|
||||
};
|
||||
refused(&path, &widen, "has mode 644");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_path_checks_still_come_first() {
|
||||
let dir = TempDir::new("race-first");
|
||||
let other = owner_file(&dir, "other", OTHER);
|
||||
let link = dir.path().join("link");
|
||||
std::os::unix::fs::symlink(&other, &link).unwrap();
|
||||
refused(
|
||||
&link,
|
||||
&|| panic!("never reached for a link"),
|
||||
"is a symbolic link",
|
||||
);
|
||||
refused(
|
||||
dir.path(),
|
||||
&|| panic!("never reached for a directory"),
|
||||
"is not a regular file",
|
||||
);
|
||||
let relative = Path::new("relative/token");
|
||||
let why = read_checked(relative, &|| panic!("never reached")).unwrap_err();
|
||||
assert!(why.contains("is not an absolute path"), "{why}");
|
||||
}
|
||||
+143
@@ -415,3 +415,146 @@ Look for wrong ownership or mode, or a file that is not UTF-8 text.
|
||||
save it again as UTF-8.
|
||||
|
||||
**Check.** A new session starts, and its `0.baseline.json` contains the text.
|
||||
|
||||
## gatewayd-start-failed
|
||||
|
||||
**What you see.** `gatewayd` exits 1 at start, before it connects, with one line naming its config
|
||||
file or a directory, then this entry.
|
||||
|
||||
**Why.** `gatewayd` could not read or parse `gatewayd.toml`, or a value in it is not allowed: a
|
||||
`url` that is not `http://` or `https://` with a host and an optional port, an id that is not 26
|
||||
characters of `a-z0-9`, an empty `[allow] users`, or a secret with not exactly one of `credential`,
|
||||
`env` and `file`. Or it could not create `<home>/gateway/`. It will not guess at a configuration
|
||||
that decides who it answers.
|
||||
|
||||
**Confirm.** The line says which: `<config path>: …` for the file (unknown keys are errors; check
|
||||
it against `docs/specs/2026-09-23-m4a-gateway.md`, section 3), or `cannot prepare <dir>: …` for the
|
||||
directory (`ls -ld <dir> "$(dirname <dir>)"`).
|
||||
|
||||
**Fix.** Correct the file or the directory's ownership. A user id is shown in Mattermost under the
|
||||
user's profile, or by `GET /api/v4/users/username/<name>`.
|
||||
|
||||
**Check.** `gatewayd serve --config <path>` prints `gatewayd: connected to <url> as <user>`.
|
||||
|
||||
## secret-unavailable
|
||||
|
||||
**What you see.** `gatewayd` exits 1 at start with `secret <name>: <reason>`, then this entry. The
|
||||
value is never printed.
|
||||
|
||||
**Why.** `gatewayd` does not start without its Mattermost token, and reads it only from the one
|
||||
place `[secrets.<name>]` names. The reasons: for `credential`, `CREDENTIALS_DIRECTORY` is unset
|
||||
(not started by systemd with a credential) or the file in it is missing; for `env`, the variable is
|
||||
unset or empty; for `file`, the path is not absolute, is a symbolic link, is not a regular file, is
|
||||
not owned by the user `gatewayd` runs as, or has any group or other permission (only 0600 or 0400
|
||||
are accepted). An empty value is refused in every form.
|
||||
|
||||
**Confirm.**
|
||||
|
||||
```sh
|
||||
systemctl --user show -p LoadCredentialEncrypted gatewayd # credential
|
||||
ls -l <path>; id -u # file: owner and mode
|
||||
```
|
||||
|
||||
**Fix.** For a credential: `systemd-creds --user encrypt --name=<credential> - <path>`, type the
|
||||
token, then give the unit `LoadCredentialEncrypted=<credential>:<path>`. For a file:
|
||||
`chmod 600 <path>` and `chown` it to the user `gatewayd` runs as. For an environment variable, set
|
||||
it in the environment `gatewayd` starts in.
|
||||
|
||||
**Check.** `gatewayd` starts and prints `gatewayd: connected to <url> as <user>`.
|
||||
|
||||
## secret-in-a-file
|
||||
|
||||
**What you see.** At start: `gatewayd: warning: secret <name> is read in plaintext from <path>; a
|
||||
systemd credential keeps it encrypted at rest`, then this entry. `gatewayd` runs normally.
|
||||
|
||||
**Why.** A file holds the token in plaintext: anyone who can read the disk, or a backup of it, can
|
||||
use it. A credential is encrypted to this machine's TPM and host key. Sometimes a file is right (a
|
||||
development machine, a system without systemd); the warning is there so the choice is deliberate.
|
||||
|
||||
**Confirm.** `[secrets.<name>]` in `gatewayd.toml` has `file = …`.
|
||||
|
||||
**Fix.** To keep the file: nothing; the warning stays. To move to a credential, follow the fix in
|
||||
[secret-unavailable](#secret-unavailable), change the entry to `credential = "<name>"`, restart,
|
||||
then delete the file and regenerate the token if the file was ever copied elsewhere.
|
||||
|
||||
**Check.** The warning is gone at the next start.
|
||||
|
||||
## mattermost-unreachable
|
||||
|
||||
**What you see.** `gatewayd` prints `gatewayd: cannot reach <url>: <reason>; trying again in <n> s`,
|
||||
then this entry, once per attempt: after 1, 2, 5 and 10 seconds, then every 30. Posts to Boxmaker go
|
||||
unanswered meanwhile; they are caught up when the connection returns.
|
||||
|
||||
**Why.** The TCP connection, the TLS handshake or the WebSocket upgrade failed, or the server went
|
||||
silent for `dead_after_ms`. A TLS failure means the certificate did not match the host in `url` or
|
||||
did not chain to the system's roots or `ca_file`; verification cannot be turned off.
|
||||
|
||||
**Confirm.**
|
||||
|
||||
```sh
|
||||
curl -sS <url>/api/v4/system/ping # the server answers
|
||||
tailscale status # for a tailnet url: the tailnet is up
|
||||
openssl s_client -connect <host>:443 -servername <host> </dev/null | head
|
||||
```
|
||||
|
||||
**Fix.** Start Mattermost, or the tailnet. For a certificate error, correct `url` to the name on
|
||||
the certificate, or give the issuing CA in `[mattermost] ca_file`. `gatewayd` keeps trying by
|
||||
itself; no restart is needed.
|
||||
|
||||
**Check.** `gatewayd: connected to <url> as <user>`, then a direct message to Boxmaker is answered.
|
||||
|
||||
## mattermost-auth-failed
|
||||
|
||||
**What you see.** `gatewayd` exits 1 with `gatewayd: Mattermost refused the token (<status>)`, then
|
||||
this entry.
|
||||
|
||||
**Why.** Mattermost answered 401 or 403: the token is wrong, revoked, or belongs to a deactivated
|
||||
user. Retrying with the same token cannot help, so `gatewayd` stops instead.
|
||||
|
||||
**Confirm.** With the token in `$T` (from the same place `gatewayd` reads it; do not paste it into a
|
||||
shared shell history):
|
||||
`curl -sS -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer $T" <url>/api/v4/users/me`
|
||||
prints 401 or 403.
|
||||
|
||||
**Fix.** In Mattermost, under the bot's or user's access tokens, create a new token and revoke the
|
||||
old one. Store it as [secret-unavailable](#secret-unavailable) describes, then start `gatewayd`.
|
||||
|
||||
**Check.** `gatewayd: connected to <url> as <user>`.
|
||||
|
||||
## gateway-state-damaged
|
||||
|
||||
**What you see.** `gatewayd` exits 1 with `<home>/gateway/state.json: <reason>`, then this entry:
|
||||
at start when the file cannot be read, or while running when it cannot be written
|
||||
(`…: cannot write: …`).
|
||||
|
||||
**Why.** The state file records which posts were handled, which threads Boxmaker is in, and which
|
||||
turns were in flight. If it cannot be read or kept up to date, `gatewayd` could answer old posts
|
||||
twice or miss threads, so it does not guess. A missing file is a first start and is fine.
|
||||
|
||||
**Confirm.** `ls -l "$BOXMAKER_HOME/gateway/state.json"` and
|
||||
`python3 -m json.tool "$BOXMAKER_HOME/gateway/state.json" >/dev/null`.
|
||||
|
||||
**Fix.** For a write failure, free space or correct the directory's ownership (`df -h`,
|
||||
`ls -ld "$BOXMAKER_HOME/gateway"`), then start `gatewayd`. If only the file's ownership or mode is
|
||||
wrong, correct it. If the content is damaged, move it
|
||||
aside (`mv state.json state.json.damaged`) and start again. That is a first start: posts sent while
|
||||
`gatewayd` was down are not answered, and threads in channels must name Boxmaker again once.
|
||||
|
||||
**Check.** `gatewayd` starts, and `state.json` is rewritten after the next post.
|
||||
|
||||
## loop-unavailable
|
||||
|
||||
**What you see.** In the Mattermost thread: "Boxmaker's loop is not running
|
||||
(see docs/runbook.md#loop-unavailable)". The messages that were waiting are dropped.
|
||||
|
||||
**Why.** `gatewayd` could not connect to `loop.sock`, or the connection closed before the turn
|
||||
ended. `gatewayd` does not retry: `loopd` may have finished and logged the turn, and sending it
|
||||
again would run it twice.
|
||||
|
||||
**Confirm.** `ls -l "$BOXMAKER_HOME/run/loop/loop.sock"` (or `[loop] socket`), and whether
|
||||
`loopd serve` is running. If it stopped, its last lines say why.
|
||||
|
||||
**Fix.** Start `loopd serve --config <path>`; if it failed, follow the entry its message names.
|
||||
Then send the message again in the thread.
|
||||
|
||||
**Check.** A direct message to Boxmaker is answered.
|
||||
|
||||
Reference in New Issue
Block a user