1#![allow(dead_code)]
44
45#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct WebhookTrigger {
52 pub id: String,
54 pub path: String,
56 pub workflow_id: String,
58 pub secret_token: Option<String>,
62}
63
64impl WebhookTrigger {
65 #[must_use]
67 pub fn new(
68 id: impl Into<String>,
69 path: impl Into<String>,
70 workflow_id: impl Into<String>,
71 ) -> Self {
72 Self {
73 id: id.into(),
74 path: path.into(),
75 workflow_id: workflow_id.into(),
76 secret_token: None,
77 }
78 }
79
80 #[must_use]
82 pub fn with_secret(mut self, secret: impl Into<String>) -> Self {
83 self.secret_token = Some(secret.into());
84 self
85 }
86}
87
88#[derive(Debug, Clone)]
94pub struct WebhookPayload {
95 pub method: String,
97 pub path: String,
99 pub body: String,
101 pub headers: Vec<(String, String)>,
104}
105
106impl WebhookPayload {
107 #[must_use]
109 pub fn header(&self, name: &str) -> Option<&str> {
110 let lower = name.to_lowercase();
111 self.headers
112 .iter()
113 .find(|(k, _)| k.to_lowercase() == lower)
114 .map(|(_, v)| v.as_str())
115 }
116}
117
118#[derive(Debug, Default)]
125pub struct WebhookTriggerRegistry {
126 pub triggers: Vec<WebhookTrigger>,
128}
129
130impl WebhookTriggerRegistry {
131 #[must_use]
133 pub fn new() -> Self {
134 Self::default()
135 }
136
137 pub fn add_trigger(&mut self, trigger: WebhookTrigger) {
140 if let Some(pos) = self.triggers.iter().position(|t| t.id == trigger.id) {
141 self.triggers[pos] = trigger;
142 } else {
143 self.triggers.push(trigger);
144 }
145 }
146
147 pub fn remove_trigger(&mut self, id: &str) -> Option<WebhookTrigger> {
149 if let Some(pos) = self.triggers.iter().position(|t| t.id == id) {
150 Some(self.triggers.remove(pos))
151 } else {
152 None
153 }
154 }
155
156 #[must_use]
164 pub fn match_trigger(&self, payload: &WebhookPayload) -> Option<&WebhookTrigger> {
165 for trigger in &self.triggers {
166 if trigger.path != payload.path {
167 continue;
168 }
169 if let Some(ref secret) = trigger.secret_token {
171 if !validate_signature(payload, secret) {
172 continue;
173 }
174 }
175 return Some(trigger);
176 }
177 None
178 }
179
180 #[must_use]
182 pub fn len(&self) -> usize {
183 self.triggers.len()
184 }
185
186 #[must_use]
188 pub fn is_empty(&self) -> bool {
189 self.triggers.is_empty()
190 }
191}
192
193#[must_use]
204pub fn xor_mac(body: &str, secret: &str) -> u64 {
205 let body_bytes = body.as_bytes();
206 let key_bytes = secret.as_bytes();
207
208 if body_bytes.is_empty() || key_bytes.is_empty() {
209 return 0;
210 }
211
212 let mut acc: u64 = 0xcbf2_9ce4_8422_2325; for (i, &b) in body_bytes.iter().enumerate() {
215 let key_byte = key_bytes[i % key_bytes.len()];
216 acc ^= u64::from(b) ^ u64::from(key_byte);
217 acc = acc.wrapping_mul(0x0000_0100_0000_01b3);
219 }
220 acc
221}
222
223#[must_use]
233pub fn validate_signature(payload: &WebhookPayload, secret: &str) -> bool {
234 let Some(sig_header) = payload.header("x-signature") else {
235 return false;
236 };
237
238 let mac_hex = if let Some(hex) = sig_header.strip_prefix("sha256=") {
239 hex
240 } else {
241 sig_header
242 };
243
244 let expected = xor_mac(&payload.body, secret);
245 let expected_hex = format!("{expected:016x}");
246
247 let a = expected_hex.as_bytes();
249 let b = mac_hex.as_bytes();
250 if a.len() != b.len() {
251 return false;
252 }
253 let diff = a
254 .iter()
255 .zip(b.iter())
256 .fold(0u8, |acc, (x, y)| acc | (x ^ y));
257 diff == 0
258}
259
260#[derive(Debug, Clone)]
283pub struct SimpleWebhookTrigger {
284 pub url: String,
286}
287
288impl SimpleWebhookTrigger {
289 #[must_use]
291 pub fn new(url: impl Into<String>) -> Self {
292 Self { url: url.into() }
293 }
294
295 #[must_use]
300 pub fn fire(&self, event: &str) -> String {
301 format!("WEBHOOK {} event={event}", self.url)
302 }
303
304 #[must_use]
306 pub fn url(&self) -> &str {
307 &self.url
308 }
309}
310
311#[cfg(test)]
316mod tests {
317 use super::*;
318
319 #[test]
324 fn xor_mac_empty_body_returns_zero() {
325 assert_eq!(xor_mac("", "secret"), 0);
326 }
327
328 #[test]
329 fn xor_mac_empty_secret_returns_zero() {
330 assert_eq!(xor_mac("hello", ""), 0);
331 }
332
333 #[test]
334 fn xor_mac_deterministic() {
335 let a = xor_mac("payload body", "my-secret");
336 let b = xor_mac("payload body", "my-secret");
337 assert_eq!(a, b);
338 }
339
340 #[test]
341 fn xor_mac_different_secrets_differ() {
342 let a = xor_mac("same body", "secret-a");
343 let b = xor_mac("same body", "secret-b");
344 assert_ne!(a, b);
345 }
346
347 #[test]
348 fn xor_mac_different_bodies_differ() {
349 let a = xor_mac("body-a", "secret");
350 let b = xor_mac("body-b", "secret");
351 assert_ne!(a, b);
352 }
353
354 fn make_payload_with_sig(body: &str, secret: &str) -> WebhookPayload {
359 let mac = xor_mac(body, secret);
360 let header_val = format!("sha256={mac:016x}");
361 WebhookPayload {
362 method: "POST".to_string(),
363 path: "/test".to_string(),
364 body: body.to_string(),
365 headers: vec![("x-signature".to_string(), header_val)],
366 }
367 }
368
369 #[test]
370 fn validate_signature_valid() {
371 let payload = make_payload_with_sig("hello world", "my-secret");
372 assert!(validate_signature(&payload, "my-secret"));
373 }
374
375 #[test]
376 fn validate_signature_wrong_secret() {
377 let payload = make_payload_with_sig("hello world", "correct-secret");
378 assert!(!validate_signature(&payload, "wrong-secret"));
379 }
380
381 #[test]
382 fn validate_signature_missing_header() {
383 let payload = WebhookPayload {
384 method: "POST".to_string(),
385 path: "/test".to_string(),
386 body: "hello".to_string(),
387 headers: vec![],
388 };
389 assert!(!validate_signature(&payload, "secret"));
390 }
391
392 #[test]
393 fn validate_signature_malformed_header() {
394 let payload = WebhookPayload {
395 method: "POST".to_string(),
396 path: "/test".to_string(),
397 body: "hello".to_string(),
398 headers: vec![("x-signature".to_string(), "notahex!!".to_string())],
399 };
400 assert!(!validate_signature(&payload, "secret"));
401 }
402
403 #[test]
408 fn header_lookup_case_insensitive() {
409 let p = WebhookPayload {
410 method: "POST".to_string(),
411 path: "/".to_string(),
412 body: String::new(),
413 headers: vec![("Content-Type".to_string(), "application/json".to_string())],
414 };
415 assert_eq!(p.header("content-type"), Some("application/json"));
416 assert_eq!(p.header("CONTENT-TYPE"), Some("application/json"));
417 assert!(p.header("x-missing").is_none());
418 }
419
420 #[test]
425 fn registry_match_by_path() {
426 let mut reg = WebhookTriggerRegistry::new();
427 reg.add_trigger(WebhookTrigger::new("t1", "/hook/ingest", "wf-001"));
428 reg.add_trigger(WebhookTrigger::new("t2", "/hook/export", "wf-002"));
429
430 let payload = WebhookPayload {
431 method: "POST".to_string(),
432 path: "/hook/ingest".to_string(),
433 body: "{}".to_string(),
434 headers: vec![],
435 };
436 let m = reg.match_trigger(&payload);
437 assert!(m.is_some());
438 assert_eq!(m.unwrap().workflow_id, "wf-001");
439 }
440
441 #[test]
442 fn registry_no_match_returns_none() {
443 let mut reg = WebhookTriggerRegistry::new();
444 reg.add_trigger(WebhookTrigger::new("t1", "/hook/known", "wf-001"));
445
446 let payload = WebhookPayload {
447 method: "POST".to_string(),
448 path: "/hook/unknown".to_string(),
449 body: "{}".to_string(),
450 headers: vec![],
451 };
452 assert!(reg.match_trigger(&payload).is_none());
453 }
454
455 #[test]
456 fn registry_secret_match_with_valid_signature() {
457 let secret = "top-secret";
458 let body = r#"{"event":"ingest_done"}"#;
459 let mac = xor_mac(body, secret);
460 let sig_header = format!("sha256={mac:016x}");
461
462 let mut reg = WebhookTriggerRegistry::new();
463 reg.add_trigger(WebhookTrigger::new("t1", "/secure", "wf-secure").with_secret(secret));
464
465 let payload = WebhookPayload {
466 method: "POST".to_string(),
467 path: "/secure".to_string(),
468 body: body.to_string(),
469 headers: vec![("x-signature".to_string(), sig_header)],
470 };
471
472 let m = reg.match_trigger(&payload);
473 assert!(m.is_some(), "should match with valid signature");
474 assert_eq!(m.unwrap().id, "t1");
475 }
476
477 #[test]
478 fn registry_secret_match_fails_with_wrong_signature() {
479 let mut reg = WebhookTriggerRegistry::new();
480 reg.add_trigger(
481 WebhookTrigger::new("t1", "/secure", "wf-secure").with_secret("correct-secret"),
482 );
483
484 let payload = WebhookPayload {
485 method: "POST".to_string(),
486 path: "/secure".to_string(),
487 body: "{}".to_string(),
488 headers: vec![(
489 "x-signature".to_string(),
490 "sha256=deadbeef00000000".to_string(),
491 )],
492 };
493
494 assert!(
495 reg.match_trigger(&payload).is_none(),
496 "should not match with wrong signature"
497 );
498 }
499
500 #[test]
501 fn registry_add_replaces_existing_id() {
502 let mut reg = WebhookTriggerRegistry::new();
503 reg.add_trigger(WebhookTrigger::new("t1", "/old-path", "wf-old"));
504 reg.add_trigger(WebhookTrigger::new("t1", "/new-path", "wf-new"));
505
506 assert_eq!(reg.len(), 1);
507 assert_eq!(reg.triggers[0].path, "/new-path");
508 }
509
510 #[test]
511 fn registry_remove_trigger() {
512 let mut reg = WebhookTriggerRegistry::new();
513 reg.add_trigger(WebhookTrigger::new("t1", "/hook", "wf-001"));
514 reg.add_trigger(WebhookTrigger::new("t2", "/hook2", "wf-002"));
515 assert_eq!(reg.len(), 2);
516
517 let removed = reg.remove_trigger("t1");
518 assert!(removed.is_some());
519 assert_eq!(reg.len(), 1);
520
521 let not_found = reg.remove_trigger("t999");
522 assert!(not_found.is_none());
523 }
524
525 #[test]
526 fn registry_is_empty() {
527 let mut reg = WebhookTriggerRegistry::new();
528 assert!(reg.is_empty());
529 reg.add_trigger(WebhookTrigger::new("t1", "/x", "wf-x"));
530 assert!(!reg.is_empty());
531 }
532}