1use std::collections::HashMap;
38
39#[derive(Debug, Clone, PartialEq, Eq)]
45pub enum WebhookEvent {
46 WorkflowStarted,
48 WorkflowCompleted,
50 WorkflowFailed,
52 StepCompleted {
54 step_name: String,
56 },
57 StepFailed {
59 step_name: String,
61 },
62}
63
64impl WebhookEvent {
65 #[must_use]
67 pub fn event_type(&self) -> &str {
68 match self {
69 Self::WorkflowStarted => "workflow.started",
70 Self::WorkflowCompleted => "workflow.completed",
71 Self::WorkflowFailed => "workflow.failed",
72 Self::StepCompleted { .. } => "step.completed",
73 Self::StepFailed { .. } => "step.failed",
74 }
75 }
76
77 #[must_use]
79 pub fn step_name(&self) -> Option<&str> {
80 match self {
81 Self::StepCompleted { step_name } | Self::StepFailed { step_name } => {
82 Some(step_name.as_str())
83 }
84 _ => None,
85 }
86 }
87}
88
89#[derive(Debug, Clone)]
91pub struct WebhookConfig {
92 pub url: String,
94 pub secret: Option<String>,
97 pub events: Vec<WebhookEvent>,
100 pub max_retries: u32,
102 pub timeout_ms: u64,
104}
105
106impl Default for WebhookConfig {
107 fn default() -> Self {
108 Self {
109 url: String::new(),
110 secret: None,
111 events: Vec::new(),
112 max_retries: 3,
113 timeout_ms: 5_000,
114 }
115 }
116}
117
118#[derive(Debug, Clone, Default)]
120pub struct WorkflowContext {
121 pub workflow_id: String,
123 pub workflow_name: String,
125 pub state: String,
127 pub variables: HashMap<String, serde_json::Value>,
129}
130
131#[derive(Debug, Clone)]
141pub struct WebhookNotifier {
142 config: WebhookConfig,
143}
144
145impl WebhookNotifier {
146 #[must_use]
148 pub fn new(config: WebhookConfig) -> Self {
149 Self { config }
150 }
151
152 #[must_use]
154 pub fn config(&self) -> &WebhookConfig {
155 &self.config
156 }
157
158 #[must_use]
169 pub fn build_payload(&self, event: &WebhookEvent, context: &WorkflowContext) -> String {
170 let timestamp_ms = std::time::SystemTime::now()
171 .duration_since(std::time::UNIX_EPOCH)
172 .unwrap_or_default()
173 .as_millis();
174
175 let mut payload = serde_json::json!({
176 "event_type": event.event_type(),
177 "workflow_id": context.workflow_id,
178 "workflow_name": context.workflow_name,
179 "state": context.state,
180 "timestamp_ms": timestamp_ms,
181 "variables": context.variables,
182 });
183
184 if let Some(step) = event.step_name() {
185 if let Some(obj) = payload.as_object_mut() {
186 obj.insert(
187 "step_name".to_string(),
188 serde_json::Value::String(step.to_string()),
189 );
190 }
191 }
192
193 payload.to_string()
194 }
195
196 #[must_use]
201 pub fn compute_signature(&self, payload: &str) -> Option<String> {
202 self.config
203 .secret
204 .as_ref()
205 .map(|secret| hmac_sha256(secret.as_bytes(), payload.as_bytes()))
206 }
207
208 #[must_use]
214 pub fn should_notify(&self, event: &WebhookEvent) -> bool {
215 self.config
216 .events
217 .iter()
218 .any(|e| e.event_type() == event.event_type())
219 }
220
221 #[must_use]
229 pub fn build_headers(&self, payload: &str) -> HashMap<String, String> {
230 let mut headers = HashMap::new();
231 headers.insert("Content-Type".to_string(), "application/json".to_string());
232
233 if let Some(sig) = self.compute_signature(payload) {
234 headers.insert("X-Hub-Signature-256".to_string(), sig);
235 }
236
237 headers
238 }
239}
240
241fn hmac_sha256(key: &[u8], message: &[u8]) -> String {
247 const BLOCK: usize = 64;
248
249 let mut k = [0u8; BLOCK];
250 if key.len() > BLOCK {
251 let h = sha256(key);
252 k[..32].copy_from_slice(&h);
253 } else {
254 k[..key.len()].copy_from_slice(key);
255 }
256
257 let mut i_key_pad = [0u8; BLOCK];
258 let mut o_key_pad = [0u8; BLOCK];
259 for i in 0..BLOCK {
260 i_key_pad[i] = k[i] ^ 0x36;
261 o_key_pad[i] = k[i] ^ 0x5c;
262 }
263
264 let mut inner_input = Vec::with_capacity(BLOCK + message.len());
265 inner_input.extend_from_slice(&i_key_pad);
266 inner_input.extend_from_slice(message);
267 let inner_hash = sha256(&inner_input);
268
269 let mut outer_input = Vec::with_capacity(BLOCK + 32);
270 outer_input.extend_from_slice(&o_key_pad);
271 outer_input.extend_from_slice(&inner_hash);
272 let outer_hash = sha256(&outer_input);
273
274 outer_hash
275 .iter()
276 .fold(String::with_capacity(64), |mut s, b| {
277 s.push_str(&format!("{b:02x}"));
278 s
279 })
280}
281
282#[allow(clippy::many_single_char_names)]
284fn sha256(data: &[u8]) -> [u8; 32] {
285 let mut h: [u32; 8] = [
286 0x6a09_e667,
287 0xbb67_ae85,
288 0x3c6e_f372,
289 0xa54f_f53a,
290 0x510e_527f,
291 0x9b05_688c,
292 0x1f83_d9ab,
293 0x5be0_cd19,
294 ];
295
296 const K: [u32; 64] = [
297 0x428a_2f98,
298 0x7137_4491,
299 0xb5c0_fbcf,
300 0xe9b5_dba5,
301 0x3956_c25b,
302 0x59f1_11f1,
303 0x923f_82a4,
304 0xab1c_5ed5,
305 0xd807_aa98,
306 0x1283_5b01,
307 0x2431_85be,
308 0x550c_7dc3,
309 0x72be_5d74,
310 0x80de_b1fe,
311 0x9bdc_06a7,
312 0xc19b_f174,
313 0xe49b_69c1,
314 0xefbe_4786,
315 0x0fc1_9dc6,
316 0x240c_a1cc,
317 0x2de9_2c6f,
318 0x4a74_84aa,
319 0x5cb0_a9dc,
320 0x76f9_88da,
321 0x983e_5152,
322 0xa831_c66d,
323 0xb003_27c8,
324 0xbf59_7fc7,
325 0xc6e0_0bf3,
326 0xd5a7_9147,
327 0x06ca_6351,
328 0x1429_2967,
329 0x27b7_0a85,
330 0x2e1b_2138,
331 0x4d2c_6dfc,
332 0x5338_0d13,
333 0x650a_7354,
334 0x766a_0abb,
335 0x81c2_c92e,
336 0x9272_2c85,
337 0xa2bf_e8a1,
338 0xa81a_664b,
339 0xc24b_8b70,
340 0xc76c_51a3,
341 0xd192_e819,
342 0xd699_0624,
343 0xf40e_3585,
344 0x106a_a070,
345 0x19a4_c116,
346 0x1e37_6c08,
347 0x2748_774c,
348 0x34b0_bcb5,
349 0x391c_0cb3,
350 0x4ed8_aa4a,
351 0x5b9c_ca4f,
352 0x682e_6ff3,
353 0x748f_82ee,
354 0x78a5_636f,
355 0x84c8_7814,
356 0x8cc7_0208,
357 0x90be_fffa,
358 0xa450_6ceb,
359 0xbef9_a3f7,
360 0xc671_78f2,
361 ];
362
363 let mut msg = data.to_vec();
364 let bit_len = (data.len() as u64).wrapping_mul(8);
365 msg.push(0x80);
366 while (msg.len() % 64) != 56 {
367 msg.push(0x00);
368 }
369 msg.extend_from_slice(&bit_len.to_be_bytes());
370
371 for chunk in msg.chunks(64) {
372 let mut w = [0u32; 64];
373 for (i, word_bytes) in chunk.chunks(4).enumerate().take(16) {
374 w[i] = u32::from_be_bytes([word_bytes[0], word_bytes[1], word_bytes[2], word_bytes[3]]);
375 }
376 for i in 16..64 {
377 let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3);
378 let s1 = w[i - 2].rotate_right(17) ^ w[i - 2].rotate_right(19) ^ (w[i - 2] >> 10);
379 w[i] = w[i - 16]
380 .wrapping_add(s0)
381 .wrapping_add(w[i - 7])
382 .wrapping_add(s1);
383 }
384
385 let [mut a, mut b, mut c, mut d, mut e, mut f, mut g, mut hh] = h;
386
387 for i in 0..64 {
388 let s1 = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25);
389 let ch = (e & f) ^ ((!e) & g);
390 let temp1 = hh
391 .wrapping_add(s1)
392 .wrapping_add(ch)
393 .wrapping_add(K[i])
394 .wrapping_add(w[i]);
395 let s0 = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22);
396 let maj = (a & b) ^ (a & c) ^ (b & c);
397 let temp2 = s0.wrapping_add(maj);
398
399 hh = g;
400 g = f;
401 f = e;
402 e = d.wrapping_add(temp1);
403 d = c;
404 c = b;
405 b = a;
406 a = temp1.wrapping_add(temp2);
407 }
408
409 h[0] = h[0].wrapping_add(a);
410 h[1] = h[1].wrapping_add(b);
411 h[2] = h[2].wrapping_add(c);
412 h[3] = h[3].wrapping_add(d);
413 h[4] = h[4].wrapping_add(e);
414 h[5] = h[5].wrapping_add(f);
415 h[6] = h[6].wrapping_add(g);
416 h[7] = h[7].wrapping_add(hh);
417 }
418
419 let mut digest = [0u8; 32];
420 for (i, &word) in h.iter().enumerate() {
421 digest[i * 4..(i + 1) * 4].copy_from_slice(&word.to_be_bytes());
422 }
423 digest
424}
425
426#[cfg(test)]
431mod tests {
432 use super::*;
433
434 fn make_notifier(events: Vec<WebhookEvent>, secret: Option<&str>) -> WebhookNotifier {
435 WebhookNotifier::new(WebhookConfig {
436 url: "https://example.com/hooks/workflow".to_string(),
437 secret: secret.map(str::to_string),
438 events,
439 max_retries: 3,
440 timeout_ms: 5_000,
441 })
442 }
443
444 fn make_context() -> WorkflowContext {
445 WorkflowContext {
446 workflow_id: "wf-001".to_string(),
447 workflow_name: "transcode-pipeline".to_string(),
448 state: "completed".to_string(),
449 variables: HashMap::new(),
450 }
451 }
452
453 #[test]
454 fn test_webhook_event_types() {
455 assert_eq!(
456 WebhookEvent::WorkflowStarted.event_type(),
457 "workflow.started"
458 );
459 assert_eq!(
460 WebhookEvent::WorkflowCompleted.event_type(),
461 "workflow.completed"
462 );
463 assert_eq!(WebhookEvent::WorkflowFailed.event_type(), "workflow.failed");
464 assert_eq!(
465 WebhookEvent::StepCompleted {
466 step_name: "encode".to_string()
467 }
468 .event_type(),
469 "step.completed"
470 );
471 assert_eq!(
472 WebhookEvent::StepFailed {
473 step_name: "qc-check".to_string()
474 }
475 .event_type(),
476 "step.failed"
477 );
478 }
479
480 #[test]
481 fn test_webhook_event_step_name() {
482 let ev = WebhookEvent::StepCompleted {
483 step_name: "encode".to_string(),
484 };
485 assert_eq!(ev.step_name(), Some("encode"));
486 assert!(WebhookEvent::WorkflowStarted.step_name().is_none());
487 }
488
489 #[test]
490 fn test_build_payload_workflow_started() {
491 let notifier = make_notifier(vec![WebhookEvent::WorkflowStarted], None);
492 let ctx = make_context();
493 let payload = notifier.build_payload(&WebhookEvent::WorkflowStarted, &ctx);
494
495 let parsed: serde_json::Value =
496 serde_json::from_str(&payload).expect("payload must be valid JSON");
497
498 assert_eq!(parsed["event_type"], "workflow.started");
499 assert_eq!(parsed["workflow_id"], "wf-001");
500 assert_eq!(parsed["workflow_name"], "transcode-pipeline");
501 assert_eq!(parsed["state"], "completed");
502 assert!(parsed["timestamp_ms"].is_number());
503 assert!(parsed["variables"].is_object());
504 assert!(parsed.get("step_name").is_none());
506 }
507
508 #[test]
509 fn test_build_payload_step_completed_includes_step_name() {
510 let notifier = make_notifier(vec![], None);
511 let ctx = make_context();
512 let ev = WebhookEvent::StepCompleted {
513 step_name: "encode".to_string(),
514 };
515 let payload = notifier.build_payload(&ev, &ctx);
516
517 let parsed: serde_json::Value =
518 serde_json::from_str(&payload).expect("payload must be valid JSON");
519 assert_eq!(parsed["step_name"], "encode");
520 assert_eq!(parsed["event_type"], "step.completed");
521 }
522
523 #[test]
524 fn test_compute_signature_no_secret_returns_none() {
525 let notifier = make_notifier(vec![], None);
526 let sig = notifier.compute_signature("payload");
527 assert!(sig.is_none(), "no secret should produce None signature");
528 }
529
530 #[test]
531 fn test_compute_signature_with_secret_returns_64_hex_chars() {
532 let notifier = make_notifier(vec![], Some("s3cr3t"));
533 let sig = notifier.compute_signature("some payload");
534 let sig_str = sig.expect("should have signature");
535 assert_eq!(sig_str.len(), 64, "HMAC-SHA256 hex should be 64 chars");
536 assert!(
537 sig_str.chars().all(|c| c.is_ascii_hexdigit()),
538 "signature should be hex digits"
539 );
540 }
541
542 #[test]
543 fn test_compute_signature_deterministic() {
544 let notifier = make_notifier(vec![], Some("key"));
545 let sig1 = notifier.compute_signature("hello");
546 let sig2 = notifier.compute_signature("hello");
547 assert_eq!(sig1, sig2, "same input should produce same signature");
548 }
549
550 #[test]
551 fn test_compute_signature_different_payloads_differ() {
552 let notifier = make_notifier(vec![], Some("key"));
553 let sig1 = notifier.compute_signature("hello");
554 let sig2 = notifier.compute_signature("world");
555 assert_ne!(
556 sig1, sig2,
557 "different payloads should produce different signatures"
558 );
559 }
560
561 #[test]
562 fn test_should_notify_matching_event() {
563 let notifier = make_notifier(
564 vec![
565 WebhookEvent::WorkflowCompleted,
566 WebhookEvent::WorkflowFailed,
567 ],
568 None,
569 );
570 assert!(notifier.should_notify(&WebhookEvent::WorkflowCompleted));
571 assert!(notifier.should_notify(&WebhookEvent::WorkflowFailed));
572 }
573
574 #[test]
575 fn test_should_notify_non_matching_event() {
576 let notifier = make_notifier(vec![WebhookEvent::WorkflowCompleted], None);
577 assert!(!notifier.should_notify(&WebhookEvent::WorkflowStarted));
578 assert!(!notifier.should_notify(&WebhookEvent::WorkflowFailed));
579 }
580
581 #[test]
582 fn test_should_notify_step_event_matches_by_type() {
583 let notifier = make_notifier(
585 vec![WebhookEvent::StepCompleted {
586 step_name: "*".to_string(),
587 }],
588 None,
589 );
590 assert!(notifier.should_notify(&WebhookEvent::StepCompleted {
591 step_name: "encode".to_string()
592 }));
593 assert!(notifier.should_notify(&WebhookEvent::StepCompleted {
594 step_name: "qc".to_string()
595 }));
596 }
597
598 #[test]
599 fn test_should_notify_empty_events_returns_false() {
600 let notifier = make_notifier(vec![], None);
601 assert!(!notifier.should_notify(&WebhookEvent::WorkflowStarted));
602 }
603
604 #[test]
605 fn test_build_headers_without_secret() {
606 let notifier = make_notifier(vec![], None);
607 let headers = notifier.build_headers("payload");
608 assert_eq!(
609 headers.get("Content-Type").map(String::as_str),
610 Some("application/json")
611 );
612 assert!(!headers.contains_key("X-Hub-Signature-256"));
613 }
614
615 #[test]
616 fn test_build_headers_with_secret() {
617 let notifier = make_notifier(vec![], Some("secret"));
618 let headers = notifier.build_headers("test payload");
619 assert_eq!(
620 headers.get("Content-Type").map(String::as_str),
621 Some("application/json")
622 );
623 let sig = headers
624 .get("X-Hub-Signature-256")
625 .expect("signature header should be present");
626 assert_eq!(sig.len(), 64);
627 }
628
629 #[test]
630 fn test_build_headers_signature_matches_compute_signature() {
631 let notifier = make_notifier(vec![], Some("my-key"));
632 let payload = "test-payload";
633 let headers = notifier.build_headers(payload);
634 let expected = notifier
635 .compute_signature(payload)
636 .expect("should have sig");
637 let actual = headers
638 .get("X-Hub-Signature-256")
639 .expect("should have header");
640 assert_eq!(*actual, expected);
641 }
642
643 #[test]
644 fn test_build_payload_with_variables() {
645 let notifier = make_notifier(vec![], None);
646 let mut vars = HashMap::new();
647 vars.insert(
648 "output_path".to_string(),
649 serde_json::json!("/out/clip.mp4"),
650 );
651 vars.insert("duration_secs".to_string(), serde_json::json!(120));
652 let ctx = WorkflowContext {
653 workflow_id: "wf-42".to_string(),
654 workflow_name: "ingest".to_string(),
655 state: "running".to_string(),
656 variables: vars,
657 };
658 let payload = notifier.build_payload(&WebhookEvent::WorkflowStarted, &ctx);
659 let parsed: serde_json::Value = serde_json::from_str(&payload).expect("valid JSON");
660 assert_eq!(parsed["variables"]["output_path"], "/out/clip.mp4");
661 assert_eq!(parsed["variables"]["duration_secs"], 120);
662 }
663
664 #[test]
665 fn test_sha256_known_empty_value() {
666 let hash = sha256(b"");
668 let hex: String = hash.iter().fold(String::new(), |mut s, b| {
669 s.push_str(&format!("{b:02x}"));
670 s
671 });
672 assert_eq!(
673 hex,
674 "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
675 );
676 }
677
678 #[test]
679 fn test_hmac_sha256_is_64_hex_chars() {
680 let mac = hmac_sha256(b"key", b"message");
681 assert_eq!(mac.len(), 64);
682 }
683}