#![allow(dead_code)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WebhookTrigger {
pub id: String,
pub path: String,
pub workflow_id: String,
pub secret_token: Option<String>,
}
impl WebhookTrigger {
#[must_use]
pub fn new(
id: impl Into<String>,
path: impl Into<String>,
workflow_id: impl Into<String>,
) -> Self {
Self {
id: id.into(),
path: path.into(),
workflow_id: workflow_id.into(),
secret_token: None,
}
}
#[must_use]
pub fn with_secret(mut self, secret: impl Into<String>) -> Self {
self.secret_token = Some(secret.into());
self
}
}
#[derive(Debug, Clone)]
pub struct WebhookPayload {
pub method: String,
pub path: String,
pub body: String,
pub headers: Vec<(String, String)>,
}
impl WebhookPayload {
#[must_use]
pub fn header(&self, name: &str) -> Option<&str> {
let lower = name.to_lowercase();
self.headers
.iter()
.find(|(k, _)| k.to_lowercase() == lower)
.map(|(_, v)| v.as_str())
}
}
#[derive(Debug, Default)]
pub struct WebhookTriggerRegistry {
pub triggers: Vec<WebhookTrigger>,
}
impl WebhookTriggerRegistry {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn add_trigger(&mut self, trigger: WebhookTrigger) {
if let Some(pos) = self.triggers.iter().position(|t| t.id == trigger.id) {
self.triggers[pos] = trigger;
} else {
self.triggers.push(trigger);
}
}
pub fn remove_trigger(&mut self, id: &str) -> Option<WebhookTrigger> {
if let Some(pos) = self.triggers.iter().position(|t| t.id == id) {
Some(self.triggers.remove(pos))
} else {
None
}
}
#[must_use]
pub fn match_trigger(&self, payload: &WebhookPayload) -> Option<&WebhookTrigger> {
for trigger in &self.triggers {
if trigger.path != payload.path {
continue;
}
if let Some(ref secret) = trigger.secret_token {
if !validate_signature(payload, secret) {
continue;
}
}
return Some(trigger);
}
None
}
#[must_use]
pub fn len(&self) -> usize {
self.triggers.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.triggers.is_empty()
}
}
#[must_use]
pub fn xor_mac(body: &str, secret: &str) -> u64 {
let body_bytes = body.as_bytes();
let key_bytes = secret.as_bytes();
if body_bytes.is_empty() || key_bytes.is_empty() {
return 0;
}
let mut acc: u64 = 0xcbf2_9ce4_8422_2325;
for (i, &b) in body_bytes.iter().enumerate() {
let key_byte = key_bytes[i % key_bytes.len()];
acc ^= u64::from(b) ^ u64::from(key_byte);
acc = acc.wrapping_mul(0x0000_0100_0000_01b3);
}
acc
}
#[must_use]
pub fn validate_signature(payload: &WebhookPayload, secret: &str) -> bool {
let Some(sig_header) = payload.header("x-signature") else {
return false;
};
let mac_hex = if let Some(hex) = sig_header.strip_prefix("sha256=") {
hex
} else {
sig_header
};
let expected = xor_mac(&payload.body, secret);
let expected_hex = format!("{expected:016x}");
let a = expected_hex.as_bytes();
let b = mac_hex.as_bytes();
if a.len() != b.len() {
return false;
}
let diff = a
.iter()
.zip(b.iter())
.fold(0u8, |acc, (x, y)| acc | (x ^ y));
diff == 0
}
#[derive(Debug, Clone)]
pub struct SimpleWebhookTrigger {
pub url: String,
}
impl SimpleWebhookTrigger {
#[must_use]
pub fn new(url: impl Into<String>) -> Self {
Self { url: url.into() }
}
#[must_use]
pub fn fire(&self, event: &str) -> String {
format!("WEBHOOK {} event={event}", self.url)
}
#[must_use]
pub fn url(&self) -> &str {
&self.url
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn xor_mac_empty_body_returns_zero() {
assert_eq!(xor_mac("", "secret"), 0);
}
#[test]
fn xor_mac_empty_secret_returns_zero() {
assert_eq!(xor_mac("hello", ""), 0);
}
#[test]
fn xor_mac_deterministic() {
let a = xor_mac("payload body", "my-secret");
let b = xor_mac("payload body", "my-secret");
assert_eq!(a, b);
}
#[test]
fn xor_mac_different_secrets_differ() {
let a = xor_mac("same body", "secret-a");
let b = xor_mac("same body", "secret-b");
assert_ne!(a, b);
}
#[test]
fn xor_mac_different_bodies_differ() {
let a = xor_mac("body-a", "secret");
let b = xor_mac("body-b", "secret");
assert_ne!(a, b);
}
fn make_payload_with_sig(body: &str, secret: &str) -> WebhookPayload {
let mac = xor_mac(body, secret);
let header_val = format!("sha256={mac:016x}");
WebhookPayload {
method: "POST".to_string(),
path: "/test".to_string(),
body: body.to_string(),
headers: vec![("x-signature".to_string(), header_val)],
}
}
#[test]
fn validate_signature_valid() {
let payload = make_payload_with_sig("hello world", "my-secret");
assert!(validate_signature(&payload, "my-secret"));
}
#[test]
fn validate_signature_wrong_secret() {
let payload = make_payload_with_sig("hello world", "correct-secret");
assert!(!validate_signature(&payload, "wrong-secret"));
}
#[test]
fn validate_signature_missing_header() {
let payload = WebhookPayload {
method: "POST".to_string(),
path: "/test".to_string(),
body: "hello".to_string(),
headers: vec![],
};
assert!(!validate_signature(&payload, "secret"));
}
#[test]
fn validate_signature_malformed_header() {
let payload = WebhookPayload {
method: "POST".to_string(),
path: "/test".to_string(),
body: "hello".to_string(),
headers: vec![("x-signature".to_string(), "notahex!!".to_string())],
};
assert!(!validate_signature(&payload, "secret"));
}
#[test]
fn header_lookup_case_insensitive() {
let p = WebhookPayload {
method: "POST".to_string(),
path: "/".to_string(),
body: String::new(),
headers: vec![("Content-Type".to_string(), "application/json".to_string())],
};
assert_eq!(p.header("content-type"), Some("application/json"));
assert_eq!(p.header("CONTENT-TYPE"), Some("application/json"));
assert!(p.header("x-missing").is_none());
}
#[test]
fn registry_match_by_path() {
let mut reg = WebhookTriggerRegistry::new();
reg.add_trigger(WebhookTrigger::new("t1", "/hook/ingest", "wf-001"));
reg.add_trigger(WebhookTrigger::new("t2", "/hook/export", "wf-002"));
let payload = WebhookPayload {
method: "POST".to_string(),
path: "/hook/ingest".to_string(),
body: "{}".to_string(),
headers: vec![],
};
let m = reg.match_trigger(&payload);
assert!(m.is_some());
assert_eq!(m.unwrap().workflow_id, "wf-001");
}
#[test]
fn registry_no_match_returns_none() {
let mut reg = WebhookTriggerRegistry::new();
reg.add_trigger(WebhookTrigger::new("t1", "/hook/known", "wf-001"));
let payload = WebhookPayload {
method: "POST".to_string(),
path: "/hook/unknown".to_string(),
body: "{}".to_string(),
headers: vec![],
};
assert!(reg.match_trigger(&payload).is_none());
}
#[test]
fn registry_secret_match_with_valid_signature() {
let secret = "top-secret";
let body = r#"{"event":"ingest_done"}"#;
let mac = xor_mac(body, secret);
let sig_header = format!("sha256={mac:016x}");
let mut reg = WebhookTriggerRegistry::new();
reg.add_trigger(WebhookTrigger::new("t1", "/secure", "wf-secure").with_secret(secret));
let payload = WebhookPayload {
method: "POST".to_string(),
path: "/secure".to_string(),
body: body.to_string(),
headers: vec![("x-signature".to_string(), sig_header)],
};
let m = reg.match_trigger(&payload);
assert!(m.is_some(), "should match with valid signature");
assert_eq!(m.unwrap().id, "t1");
}
#[test]
fn registry_secret_match_fails_with_wrong_signature() {
let mut reg = WebhookTriggerRegistry::new();
reg.add_trigger(
WebhookTrigger::new("t1", "/secure", "wf-secure").with_secret("correct-secret"),
);
let payload = WebhookPayload {
method: "POST".to_string(),
path: "/secure".to_string(),
body: "{}".to_string(),
headers: vec![(
"x-signature".to_string(),
"sha256=deadbeef00000000".to_string(),
)],
};
assert!(
reg.match_trigger(&payload).is_none(),
"should not match with wrong signature"
);
}
#[test]
fn registry_add_replaces_existing_id() {
let mut reg = WebhookTriggerRegistry::new();
reg.add_trigger(WebhookTrigger::new("t1", "/old-path", "wf-old"));
reg.add_trigger(WebhookTrigger::new("t1", "/new-path", "wf-new"));
assert_eq!(reg.len(), 1);
assert_eq!(reg.triggers[0].path, "/new-path");
}
#[test]
fn registry_remove_trigger() {
let mut reg = WebhookTriggerRegistry::new();
reg.add_trigger(WebhookTrigger::new("t1", "/hook", "wf-001"));
reg.add_trigger(WebhookTrigger::new("t2", "/hook2", "wf-002"));
assert_eq!(reg.len(), 2);
let removed = reg.remove_trigger("t1");
assert!(removed.is_some());
assert_eq!(reg.len(), 1);
let not_found = reg.remove_trigger("t999");
assert!(not_found.is_none());
}
#[test]
fn registry_is_empty() {
let mut reg = WebhookTriggerRegistry::new();
assert!(reg.is_empty());
reg.add_trigger(WebhookTrigger::new("t1", "/x", "wf-x"));
assert!(!reg.is_empty());
}
}