use std::fmt;
use serde::{Deserialize, Serialize};
pub const PREFIX: &str = "KASL_WEBHOOK_";
const TELEGRAM_API: &str = "https://api.telegram.org";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Kind {
Slack,
Mattermost,
Telegram,
Json,
}
impl Kind {
fn parse(raw: &str) -> Result<Self, String> {
match raw {
"slack" => Ok(Self::Slack),
"mattermost" => Ok(Self::Mattermost),
"telegram" => Ok(Self::Telegram),
"json" => Ok(Self::Json),
other => Err(format!("unknown kind `{}`: expected slack, mattermost, telegram or json", first_word(other))),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)]
#[sqlx(type_name = "webhook_event")]
pub enum EventKind {
#[serde(rename = "alert.raised")]
#[sqlx(rename = "alert.raised")]
AlertRaised,
#[serde(rename = "alert.acknowledged")]
#[sqlx(rename = "alert.acknowledged")]
AlertAcknowledged,
#[serde(rename = "alert.resolved")]
#[sqlx(rename = "alert.resolved")]
AlertResolved,
#[serde(rename = "day.closed")]
#[sqlx(rename = "day.closed")]
DayClosed,
#[serde(rename = "test")]
#[sqlx(rename = "test")]
Test,
}
impl EventKind {
pub const SUBSCRIBABLE: [Self; 4] = [Self::AlertRaised, Self::AlertAcknowledged, Self::AlertResolved, Self::DayClosed];
const DEFAULT: [Self; 3] = [Self::AlertRaised, Self::AlertAcknowledged, Self::AlertResolved];
pub fn name(self) -> &'static str {
match self {
Self::AlertRaised => "alert.raised",
Self::AlertAcknowledged => "alert.acknowledged",
Self::AlertResolved => "alert.resolved",
Self::DayClosed => "day.closed",
Self::Test => "test",
}
}
fn parse(raw: &str) -> Result<Self, String> {
Self::SUBSCRIBABLE.into_iter().find(|kind| kind.name() == raw).ok_or_else(|| {
let known: Vec<&str> = Self::SUBSCRIBABLE.iter().map(|kind| kind.name()).collect();
format!("unknown event `{}`: expected one of {}", first_word(raw), known.join(", "))
})
}
}
#[derive(Clone, PartialEq, Eq)]
pub(super) enum Target {
Hook { url: String },
Telegram { token: String, chat: String, api: String },
Json { url: String, secret: String },
}
impl fmt::Debug for Target {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("Target(..)")
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Destination {
pub name: String,
pub kind: Kind,
pub(super) target: Target,
pub events: Vec<EventKind>,
pub department: Option<String>,
}
impl Destination {
pub fn parse(variable: &str, value: &str) -> Result<Self, String> {
let name = label(variable)?;
let fail = |reason: String| format!("{variable}: {reason}");
let tokens = tokenize(value).map_err(fail)?;
let mut tokens = tokens.into_iter();
let kind = Kind::parse(&tokens.next().ok_or_else(|| fail("is empty; expected a kind and a target".into()))?).map_err(fail)?;
let target = tokens.next().ok_or_else(|| fail("names a kind and no target".into()))?;
let mut events: Option<Vec<EventKind>> = None;
let mut department = None;
let mut chat = None;
let mut secret = None;
for option in tokens {
let Some((key, value)) = option.split_once('=') else {
return Err(fail(format!("`{}` is not an option; options are written key=value", first_word(&option))));
};
let slot = match key {
"events" => {
if events.is_some() {
return Err(fail("names `events` twice".into()));
}
let parsed = value
.split(',')
.map(str::trim)
.filter(|name| !name.is_empty())
.map(EventKind::parse)
.collect::<Result<Vec<_>, _>>()
.map_err(fail)?;
if parsed.is_empty() {
return Err(fail("`events=` lists nothing; leave it out to hear about alerts".into()));
}
events = Some(parsed);
continue;
}
"department" => &mut department,
"chat" => &mut chat,
"secret" => &mut secret,
other => {
return Err(fail(format!(
"unknown option `{}`: expected events, department, chat or secret",
first_word(other)
)));
}
};
if slot.is_some() {
return Err(fail(format!("names `{key}` twice")));
}
if value.is_empty() {
return Err(fail(format!("`{key}=` is empty")));
}
*slot = Some(value.to_string());
}
if chat.is_some() && kind != Kind::Telegram {
return Err(fail("`chat=` belongs to a telegram destination".into()));
}
if secret.is_some() && kind != Kind::Json {
return Err(fail("`secret=` belongs to a json destination".into()));
}
let target = match kind {
Kind::Slack | Kind::Mattermost => Target::Hook {
url: http_url(&target).map_err(fail)?,
},
Kind::Telegram => {
let well_formed = target
.split_once(':')
.is_some_and(|(bot, key)| !bot.is_empty() && bot.bytes().all(|b| b.is_ascii_digit()) && !key.is_empty());
if !well_formed {
return Err(fail("the target of a telegram destination is the bot token, `123456:ABC...`".into()));
}
let chat = chat.ok_or_else(|| fail("a telegram destination needs `chat=` - the chat id the bot posts into".into()))?;
Target::Telegram {
token: target,
chat,
api: TELEGRAM_API.to_string(),
}
}
Kind::Json => {
let secret = secret.ok_or_else(|| fail("a json destination needs `secret=` - the receiver checks the signature with it".into()))?;
Target::Json {
url: http_url(&target).map_err(fail)?,
secret,
}
}
};
Ok(Self {
name,
kind,
target,
events: events.unwrap_or_else(|| EventKind::DEFAULT.to_vec()),
department,
})
}
pub fn hears(&self, event: EventKind) -> bool {
self.events.contains(&event)
}
pub fn shown_target(&self) -> String {
match &self.target {
Target::Hook { url } | Target::Json { url, .. } => host(url).to_string(),
Target::Telegram { chat, .. } => format!("chat {chat}"),
}
}
pub fn redact(&self, text: &str) -> String {
let secrets: Vec<&str> = match &self.target {
Target::Hook { url } => vec![url.as_str()],
Target::Telegram { token, .. } => vec![token.as_str()],
Target::Json { url, secret } => vec![url.as_str(), secret.as_str()],
};
secrets
.into_iter()
.filter(|secret| !secret.is_empty())
.fold(text.to_string(), |text, secret| text.replace(secret, "…"))
}
#[doc(hidden)]
pub fn with_telegram_api(mut self, api: &str) -> Self {
if let Target::Telegram { api: current, .. } = &mut self.target {
*current = api.trim_end_matches('/').to_string();
}
self
}
}
fn label(variable: &str) -> Result<String, String> {
let suffix = variable.strip_prefix(PREFIX).unwrap_or_default();
if suffix.is_empty() || !suffix.bytes().all(|b| b.is_ascii_uppercase() || b.is_ascii_digit() || b == b'_') {
return Err(format!(
"{variable}: a destination is named `{PREFIX}<NAME>`, in capital letters, digits and underscores"
));
}
Ok(suffix.to_ascii_lowercase().replace('_', "-"))
}
fn tokenize(value: &str) -> Result<Vec<String>, String> {
let mut tokens = Vec::new();
let mut current = String::new();
let mut quoted = false;
let mut started = false;
for c in value.chars() {
match c {
'"' => {
quoted = !quoted;
started = true;
}
c if c.is_whitespace() && !quoted => {
if started {
tokens.push(std::mem::take(&mut current));
started = false;
}
}
c => {
current.push(c);
started = true;
}
}
}
if quoted {
return Err("has a quote that is never closed".into());
}
if started {
tokens.push(current);
}
Ok(tokens)
}
fn first_word(token: &str) -> String {
const QUOTABLE: usize = 20;
if token.chars().count() <= QUOTABLE {
token.to_string()
} else {
format!("{}…", token.chars().take(8).collect::<String>())
}
}
fn http_url(raw: &str) -> Result<String, String> {
let rest = raw.strip_prefix("https://").or_else(|| raw.strip_prefix("http://"));
match rest {
Some(rest) if !host(raw).is_empty() && !rest.starts_with('/') => Ok(raw.to_string()),
_ => Err("the target is not an http(s) address".into()),
}
}
fn host(url: &str) -> &str {
let rest = url.split_once("://").map(|(_, rest)| rest).unwrap_or(url);
let authority = rest.split(['/', '?', '#']).next().unwrap_or_default();
let authority = authority.rsplit_once('@').map(|(_, host)| host).unwrap_or(authority);
authority.split(':').next().unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
const HOOK: &str = "https://hooks.slack.com/services/T000/B000/SECRETSECRETSECRETSECRET";
#[test]
fn a_slack_hook_hears_about_alerts_by_default() {
let destination = Destination::parse("KASL_WEBHOOK_TEAM_CHAT", &format!("slack {HOOK}")).unwrap();
assert_eq!(destination.name, "team-chat");
assert_eq!(destination.kind, Kind::Slack);
assert!(destination.hears(EventKind::AlertRaised));
assert!(destination.hears(EventKind::AlertResolved));
assert!(
!destination.hears(EventKind::DayClosed),
"a day closes for everyone every day; hearing that is opted into"
);
assert_eq!(destination.department, None);
}
#[test]
fn options_narrow_what_it_hears_and_about_whom() {
let destination = Destination::parse(
"KASL_WEBHOOK_CS",
&format!(r#"mattermost {HOOK} events=day.closed,alert.raised department="Customer Success""#),
)
.unwrap();
assert_eq!(destination.events, vec![EventKind::DayClosed, EventKind::AlertRaised]);
assert_eq!(destination.department.as_deref(), Some("Customer Success"));
}
#[test]
fn a_telegram_destination_needs_a_bot_token_and_a_chat() {
let destination = Destination::parse("KASL_WEBHOOK_OPS", "telegram 123456:ABC-DEF chat=-100200300").unwrap();
assert_eq!(destination.shown_target(), "chat -100200300");
let error = Destination::parse("KASL_WEBHOOK_OPS", "telegram 123456:ABC-DEF").unwrap_err();
assert!(error.contains("chat="), "{error}");
let error = Destination::parse("KASL_WEBHOOK_OPS", "telegram -100200300 chat=-100200300").unwrap_err();
assert!(error.contains("bot token"), "{error}");
}
#[test]
fn a_json_destination_is_signed_or_refused() {
let error = Destination::parse("KASL_WEBHOOK_PAYROLL", "json https://payroll.example.com/in").unwrap_err();
assert!(error.contains("secret="), "{error}");
assert!(Destination::parse("KASL_WEBHOOK_PAYROLL", "json https://payroll.example.com/in secret=abc").is_ok());
}
#[test]
fn an_option_for_another_kind_is_refused_rather_than_ignored() {
let error = Destination::parse("KASL_WEBHOOK_TEAM", &format!("slack {HOOK} chat=1")).unwrap_err();
assert!(error.contains("telegram"), "{error}");
let error = Destination::parse("KASL_WEBHOOK_TEAM", &format!("slack {HOOK} secret=1")).unwrap_err();
assert!(error.contains("json"), "{error}");
}
#[test]
fn mistakes_are_refused_with_the_variable_named() {
for (value, expected) in [
("", "empty"),
("discord https://x.example", "unknown kind"),
("slack", "no target"),
("slack ftp://x.example", "http(s)"),
("slack https://", "http(s)"),
(&format!("slack {HOOK} events=alert.exploded"), "unknown event"),
(&format!("slack {HOOK} events=test"), "unknown event"),
(&format!("slack {HOOK} events="), "lists nothing"),
(&format!("slack {HOOK} events=day.closed events=alert.raised"), "twice"),
(&format!("slack {HOOK} colour=red"), "unknown option"),
(&format!("slack {HOOK} department=\"Design"), "never closed"),
(&format!("slack {HOOK} department="), "empty"),
] {
let error = Destination::parse("KASL_WEBHOOK_TEAM", value).unwrap_err();
assert!(error.starts_with("KASL_WEBHOOK_TEAM:"), "the variable is named: {error}");
assert!(error.contains(expected), "`{value}` should say {expected}: {error}");
}
let error = Destination::parse("KASL_WEBHOOK_", &format!("slack {HOOK}")).unwrap_err();
assert!(error.contains("<NAME>"), "{error}");
let error = Destination::parse("KASL_WEBHOOK_team", &format!("slack {HOOK}")).unwrap_err();
assert!(error.contains("capital"), "{error}");
}
#[test]
fn no_error_repeats_the_credential() {
for value in [
format!("slack {HOOK} stray-SECRETSECRETSECRETSECRET"),
format!("slack {HOOK} events=SECRETSECRETSECRETSECRET"),
format!("{HOOK}SECRETSECRETSECRETSECRET slack"),
format!("slack {HOOK} SECRETSECRETSECRETSECRET=1"),
"telegram 99:SECRETSECRETSECRETSECRET".to_string(),
"json https://x.example/SECRETSECRETSECRETSECRET".to_string(),
] {
let error = Destination::parse("KASL_WEBHOOK_TEAM", &value).unwrap_err();
assert!(!error.contains("SECRETSECRETSECRETSECRET"), "the error quotes the credential: {error}");
}
}
#[test]
fn what_is_shown_is_the_host_and_never_the_hook() {
let destination = Destination::parse("KASL_WEBHOOK_TEAM", &format!("slack {HOOK}")).unwrap();
assert_eq!(destination.shown_target(), "hooks.slack.com");
assert!(!format!("{destination:?}").contains("SECRET"), "Debug must not print the target");
assert_eq!(host("https://user:pw@chat.example.com:8443/hooks/x?y=1"), "chat.example.com");
}
#[test]
fn redaction_removes_every_credential_a_target_holds() {
let destination = Destination::parse("KASL_WEBHOOK_P", "json https://p.example/in secret=s3cr3t-value").unwrap();
let redacted = destination.redact("POST https://p.example/in failed, signed with s3cr3t-value");
assert!(!redacted.contains("https://p.example/in"), "{redacted}");
assert!(!redacted.contains("s3cr3t-value"), "{redacted}");
let destination = Destination::parse("KASL_WEBHOOK_T", "telegram 42:TOKEN chat=1").unwrap();
assert_eq!(
destination.redact("https://api.telegram.org/bot42:TOKEN/sendMessage"),
"https://api.telegram.org/bot…/sendMessage"
);
}
}