1#![allow(dead_code)]
18
19use std::collections::HashMap;
20
21#[derive(Debug, Clone)]
23pub enum TriggerType {
24 Schedule(ScheduleTrigger),
26 FileArrival(FileArrivalTrigger),
28 ApiCall,
30 EventBased(EventTrigger),
32 ManualStart,
34 Dependency,
36 Webhook(WebhookTrigger),
38}
39
40#[derive(Debug, Clone)]
42pub struct ScheduleTrigger {
43 pub cron_expr: String,
45 pub timezone: String,
47 pub max_runs: Option<u32>,
49}
50
51impl ScheduleTrigger {
52 #[must_use]
54 pub fn new(cron_expr: impl Into<String>, timezone: impl Into<String>) -> Self {
55 Self {
56 cron_expr: cron_expr.into(),
57 timezone: timezone.into(),
58 max_runs: None,
59 }
60 }
61
62 #[must_use]
64 pub fn with_max_runs(mut self, max_runs: u32) -> Self {
65 self.max_runs = Some(max_runs);
66 self
67 }
68
69 #[must_use]
75 pub fn next_fire_ms(&self, now_ms: u64) -> u64 {
76 let parts: Vec<&str> = self.cron_expr.split_whitespace().collect();
79 if parts.len() < 3 {
80 return now_ms + 3_600_000;
82 }
83
84 let minute: u64 = parts[1].parse().unwrap_or(0);
85 let hour: u64 = parts[2].parse().unwrap_or(0);
86
87 let now_secs = now_ms / 1000;
89 let seconds_in_day = now_secs % 86400;
90 let current_hour = seconds_in_day / 3600;
91 let current_minute = (seconds_in_day % 3600) / 60;
92 let day_start_ms = now_ms - (seconds_in_day * 1000);
93
94 let target_ms = day_start_ms + hour * 3_600_000 + minute * 60_000;
95
96 if target_ms > now_ms {
97 target_ms
98 } else if hour == current_hour && minute == current_minute {
99 now_ms + 60_000
101 } else {
102 target_ms + 86_400_000
104 }
105 }
106}
107
108#[derive(Debug, Clone)]
110pub struct FileArrivalTrigger {
111 pub watch_path: String,
113 pub pattern: String,
115 pub min_size_bytes: u64,
117 pub stable_for_secs: u32,
119}
120
121impl FileArrivalTrigger {
122 #[must_use]
124 pub fn new(
125 watch_path: impl Into<String>,
126 pattern: impl Into<String>,
127 min_size_bytes: u64,
128 stable_for_secs: u32,
129 ) -> Self {
130 Self {
131 watch_path: watch_path.into(),
132 pattern: pattern.into(),
133 min_size_bytes,
134 stable_for_secs,
135 }
136 }
137
138 #[must_use]
142 pub fn matches(&self, path: &str, size: u64) -> bool {
143 if size < self.min_size_bytes {
144 return false;
145 }
146
147 let filename = path.rsplit('/').next().unwrap_or(path);
149
150 glob_match(&self.pattern, filename)
151 }
152}
153
154fn glob_match(pattern: &str, text: &str) -> bool {
156 let pattern_bytes = pattern.as_bytes();
157 let text_bytes = text.as_bytes();
158 glob_match_inner(pattern_bytes, text_bytes)
159}
160
161fn glob_match_inner(pattern: &[u8], text: &[u8]) -> bool {
162 match (pattern.first(), text.first()) {
163 (None, None) => true,
164 (Some(&b'*'), _) => {
165 glob_match_inner(&pattern[1..], text)
167 || (!text.is_empty() && glob_match_inner(pattern, &text[1..]))
168 }
169 (None, Some(_)) | (Some(_), None) => false,
170 (Some(&p), Some(&t)) => p == t && glob_match_inner(&pattern[1..], &text[1..]),
171 }
172}
173
174#[derive(Debug, Clone)]
176pub struct EventTrigger {
177 pub event_type: String,
179 pub filter: HashMap<String, String>,
181}
182
183impl EventTrigger {
184 #[must_use]
186 pub fn new(event_type: impl Into<String>) -> Self {
187 Self {
188 event_type: event_type.into(),
189 filter: HashMap::new(),
190 }
191 }
192
193 #[must_use]
195 pub fn with_filter(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
196 self.filter.insert(key.into(), value.into());
197 self
198 }
199
200 #[must_use]
202 pub fn matches(&self, event: &HashMap<String, String>) -> bool {
203 for (key, expected_value) in &self.filter {
204 match event.get(key) {
205 Some(actual_value) if actual_value == expected_value => {}
206 _ => return false,
207 }
208 }
209 true
210 }
211}
212
213#[derive(Debug, Clone)]
227pub struct WebhookTrigger {
228 pub path: String,
230 pub secret: Option<String>,
232 pub required_fields: HashMap<String, String>,
234 pub description: String,
236}
237
238impl WebhookTrigger {
239 #[must_use]
241 pub fn new(path: impl Into<String>) -> Self {
242 Self {
243 path: path.into(),
244 secret: None,
245 required_fields: HashMap::new(),
246 description: String::new(),
247 }
248 }
249
250 #[must_use]
252 pub fn with_secret(mut self, secret: impl Into<String>) -> Self {
253 self.secret = Some(secret.into());
254 self
255 }
256
257 #[must_use]
259 pub fn with_required_field(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
260 self.required_fields.insert(key.into(), value.into());
261 self
262 }
263
264 #[must_use]
266 pub fn with_description(mut self, desc: impl Into<String>) -> Self {
267 self.description = desc.into();
268 self
269 }
270
271 #[must_use]
278 pub fn verify_signature(&self, payload: &[u8], signature: &str) -> bool {
279 let Some(ref secret) = self.secret else {
280 return true; };
282 let expected = hmac_sha256(secret.as_bytes(), payload);
283 constant_time_eq(&expected, signature.trim())
285 }
286
287 #[must_use]
289 pub fn body_matches(&self, body: &serde_json::Value) -> bool {
290 for (key, expected) in &self.required_fields {
291 match body.get(key).and_then(|v| v.as_str()) {
292 Some(actual) if actual == expected.as_str() => {}
293 _ => return false,
294 }
295 }
296 true
297 }
298}
299
300fn hmac_sha256(key: &[u8], message: &[u8]) -> String {
308 const BLOCK: usize = 64;
310
311 let mut k = [0u8; BLOCK];
313 if key.len() > BLOCK {
314 let h = sha256(key);
315 k[..32].copy_from_slice(&h);
316 } else {
317 k[..key.len()].copy_from_slice(key);
318 }
319
320 let mut i_key_pad = [0u8; BLOCK];
322 let mut o_key_pad = [0u8; BLOCK];
323 for i in 0..BLOCK {
324 i_key_pad[i] = k[i] ^ 0x36;
325 o_key_pad[i] = k[i] ^ 0x5c;
326 }
327
328 let mut inner_input = Vec::with_capacity(BLOCK + message.len());
330 inner_input.extend_from_slice(&i_key_pad);
331 inner_input.extend_from_slice(message);
332 let inner_hash = sha256(&inner_input);
333
334 let mut outer_input = Vec::with_capacity(BLOCK + 32);
336 outer_input.extend_from_slice(&o_key_pad);
337 outer_input.extend_from_slice(&inner_hash);
338 let outer_hash = sha256(&outer_input);
339
340 outer_hash
342 .iter()
343 .fold(String::with_capacity(64), |mut s, b| {
344 s.push_str(&format!("{b:02x}"));
345 s
346 })
347}
348
349#[allow(clippy::many_single_char_names)]
351fn sha256(data: &[u8]) -> [u8; 32] {
352 let mut h: [u32; 8] = [
354 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab,
355 0x5be0cd19,
356 ];
357
358 const K: [u32; 64] = [
360 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4,
361 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe,
362 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f,
363 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7,
364 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc,
365 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b,
366 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116,
367 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
368 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7,
369 0xc67178f2,
370 ];
371
372 let mut msg = data.to_vec();
374 let bit_len = (data.len() as u64).wrapping_mul(8);
375 msg.push(0x80);
376 while (msg.len() % 64) != 56 {
377 msg.push(0x00);
378 }
379 msg.extend_from_slice(&bit_len.to_be_bytes());
380
381 for chunk in msg.chunks(64) {
383 let mut w = [0u32; 64];
384 for (i, word_bytes) in chunk.chunks(4).enumerate().take(16) {
385 w[i] = u32::from_be_bytes([word_bytes[0], word_bytes[1], word_bytes[2], word_bytes[3]]);
386 }
387 for i in 16..64 {
388 let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3);
389 let s1 = w[i - 2].rotate_right(17) ^ w[i - 2].rotate_right(19) ^ (w[i - 2] >> 10);
390 w[i] = w[i - 16]
391 .wrapping_add(s0)
392 .wrapping_add(w[i - 7])
393 .wrapping_add(s1);
394 }
395
396 let [mut a, mut b, mut c, mut d, mut e, mut f, mut g, mut hh] = h;
397
398 for i in 0..64 {
399 let s1 = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25);
400 let ch = (e & f) ^ ((!e) & g);
401 let temp1 = hh
402 .wrapping_add(s1)
403 .wrapping_add(ch)
404 .wrapping_add(K[i])
405 .wrapping_add(w[i]);
406 let s0 = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22);
407 let maj = (a & b) ^ (a & c) ^ (b & c);
408 let temp2 = s0.wrapping_add(maj);
409
410 hh = g;
411 g = f;
412 f = e;
413 e = d.wrapping_add(temp1);
414 d = c;
415 c = b;
416 b = a;
417 a = temp1.wrapping_add(temp2);
418 }
419
420 h[0] = h[0].wrapping_add(a);
421 h[1] = h[1].wrapping_add(b);
422 h[2] = h[2].wrapping_add(c);
423 h[3] = h[3].wrapping_add(d);
424 h[4] = h[4].wrapping_add(e);
425 h[5] = h[5].wrapping_add(f);
426 h[6] = h[6].wrapping_add(g);
427 h[7] = h[7].wrapping_add(hh);
428 }
429
430 let mut digest = [0u8; 32];
431 for (i, &word) in h.iter().enumerate() {
432 digest[i * 4..(i + 1) * 4].copy_from_slice(&word.to_be_bytes());
433 }
434 digest
435}
436
437fn constant_time_eq(a: &str, b: &str) -> bool {
439 let ab = a.as_bytes();
440 let bb = b.as_bytes();
441 if ab.len() != bb.len() {
442 return false;
443 }
444 let mut diff = 0u8;
445 for (x, y) in ab.iter().zip(bb.iter()) {
446 diff |= x ^ y;
447 }
448 diff == 0
449}
450
451#[derive(Debug, Default)]
461pub struct WebhookRouter {
462 routes: HashMap<String, Vec<WebhookTrigger>>,
464}
465
466impl WebhookRouter {
467 #[must_use]
469 pub fn new() -> Self {
470 Self::default()
471 }
472
473 pub fn register(&mut self, workflow_id: impl Into<String>, trigger: WebhookTrigger) {
475 self.routes
476 .entry(workflow_id.into())
477 .or_default()
478 .push(trigger);
479 }
480
481 pub fn remove(&mut self, workflow_id: &str) {
483 self.routes.remove(workflow_id);
484 }
485
486 #[must_use]
498 pub fn route(
499 &self,
500 path: &str,
501 body_bytes: &[u8],
502 body_json: &serde_json::Value,
503 signature: Option<&str>,
504 ) -> Vec<String> {
505 let mut triggered = Vec::new();
506
507 for (workflow_id, triggers) in &self.routes {
508 for trigger in triggers {
509 if trigger.path != path {
510 continue;
511 }
512 let sig = signature.unwrap_or("");
514 if !trigger.verify_signature(body_bytes, sig) {
515 continue;
516 }
517 if trigger.body_matches(body_json) {
518 triggered.push(workflow_id.clone());
519 break; }
521 }
522 }
523
524 triggered
525 }
526
527 #[must_use]
529 pub fn workflow_ids(&self) -> Vec<&str> {
530 self.routes.keys().map(String::as_str).collect()
531 }
532
533 #[must_use]
535 pub fn trigger_count(&self) -> usize {
536 self.routes.values().map(Vec::len).sum()
537 }
538}
539
540#[derive(Debug, Clone)]
542pub enum TriggerCondition {
543 All(Vec<TriggerType>),
545 Any(Vec<TriggerType>),
547 None(Vec<TriggerType>),
549}
550
551#[derive(Debug, Default)]
553pub struct TriggerEngine {
554 triggers: HashMap<String, Vec<TriggerType>>,
556}
557
558impl TriggerEngine {
559 #[must_use]
561 pub fn new() -> Self {
562 Self {
563 triggers: HashMap::new(),
564 }
565 }
566
567 pub fn add_trigger(&mut self, workflow_id: &str, trigger: TriggerType) {
569 self.triggers
570 .entry(workflow_id.to_string())
571 .or_default()
572 .push(trigger);
573 }
574
575 #[must_use]
577 pub fn evaluate_file_arrival(&self, path: &str, size: u64) -> Vec<String> {
578 let mut triggered = Vec::new();
579
580 for (workflow_id, triggers) in &self.triggers {
581 for trigger in triggers {
582 if let TriggerType::FileArrival(file_trigger) = trigger {
583 if file_trigger.matches(path, size) {
584 triggered.push(workflow_id.clone());
585 break; }
587 }
588 }
589 }
590
591 triggered
592 }
593
594 #[must_use]
596 pub fn evaluate_event(&self, event: &HashMap<String, String>) -> Vec<String> {
597 let mut triggered = Vec::new();
598
599 for (workflow_id, triggers) in &self.triggers {
600 for trigger in triggers {
601 if let TriggerType::EventBased(event_trigger) = trigger {
602 if event_trigger.matches(event) {
603 triggered.push(workflow_id.clone());
604 break;
605 }
606 }
607 }
608 }
609
610 triggered
611 }
612
613 #[must_use]
615 pub fn get_triggers(&self, workflow_id: &str) -> &[TriggerType] {
616 self.triggers.get(workflow_id).map_or(&[], Vec::as_slice)
617 }
618
619 pub fn remove_workflow(&mut self, workflow_id: &str) {
621 self.triggers.remove(workflow_id);
622 }
623
624 #[must_use]
626 pub fn workflow_ids(&self) -> Vec<&str> {
627 self.triggers.keys().map(String::as_str).collect()
628 }
629}
630
631#[cfg(test)]
632mod tests {
633 use super::*;
634
635 #[test]
636 fn test_schedule_trigger_next_fire_future_today() {
637 let now_ms = 8 * 3_600_000_u64; let trigger = ScheduleTrigger::new("0 0 9 * * *", "UTC");
640 let next = trigger.next_fire_ms(now_ms);
641 assert_eq!(next, 9 * 3_600_000);
643 }
644
645 #[test]
646 fn test_schedule_trigger_next_fire_tomorrow() {
647 let now_ms = 10 * 3_600_000_u64;
649 let trigger = ScheduleTrigger::new("0 0 9 * * *", "UTC");
650 let next = trigger.next_fire_ms(now_ms);
651 assert!(next > now_ms);
652 }
653
654 #[test]
655 fn test_schedule_trigger_with_max_runs() {
656 let trigger = ScheduleTrigger::new("0 0 9 * * *", "UTC").with_max_runs(5);
657 assert_eq!(trigger.max_runs, Some(5));
658 }
659
660 #[test]
661 fn test_file_arrival_trigger_matches_pattern() {
662 let trigger = FileArrivalTrigger::new("/watch", "*.mp4", 1000, 5);
663 assert!(trigger.matches("/watch/video.mp4", 5000));
664 assert!(!trigger.matches("/watch/video.mp4", 500)); assert!(!trigger.matches("/watch/video.mov", 5000)); }
667
668 #[test]
669 fn test_file_arrival_trigger_wildcard_pattern() {
670 let trigger = FileArrivalTrigger::new("/ingest", "mxf_*_v2.mxf", 0, 0);
671 assert!(trigger.matches("/ingest/mxf_cam1_v2.mxf", 0));
672 assert!(!trigger.matches("/ingest/mxf_cam1_v1.mxf", 0));
673 }
674
675 #[test]
676 fn test_file_arrival_trigger_no_wildcard() {
677 let trigger = FileArrivalTrigger::new("/dir", "exact.mp4", 0, 0);
678 assert!(trigger.matches("/dir/exact.mp4", 0));
679 assert!(!trigger.matches("/dir/other.mp4", 0));
680 }
681
682 #[test]
683 fn test_event_trigger_matches() {
684 let trigger = EventTrigger::new("media.ready")
685 .with_filter("format", "mp4")
686 .with_filter("resolution", "4k");
687
688 let mut event = HashMap::new();
689 event.insert("format".to_string(), "mp4".to_string());
690 event.insert("resolution".to_string(), "4k".to_string());
691 event.insert("extra_field".to_string(), "ignored".to_string());
692
693 assert!(trigger.matches(&event));
694 }
695
696 #[test]
697 fn test_event_trigger_no_match() {
698 let trigger = EventTrigger::new("media.ready").with_filter("format", "mp4");
699
700 let mut event = HashMap::new();
701 event.insert("format".to_string(), "mov".to_string());
702
703 assert!(!trigger.matches(&event));
704 }
705
706 #[test]
707 fn test_event_trigger_empty_filter() {
708 let trigger = EventTrigger::new("any.event");
709 let event = HashMap::new();
710 assert!(trigger.matches(&event));
711 }
712
713 #[test]
714 fn test_trigger_engine_add_and_evaluate_file() {
715 let mut engine = TriggerEngine::new();
716 engine.add_trigger(
717 "workflow-1",
718 TriggerType::FileArrival(FileArrivalTrigger::new("/ingest", "*.mxf", 1000, 5)),
719 );
720 engine.add_trigger(
721 "workflow-2",
722 TriggerType::FileArrival(FileArrivalTrigger::new("/ingest", "*.mp4", 1000, 5)),
723 );
724
725 let triggered = engine.evaluate_file_arrival("/ingest/clip.mxf", 50_000);
726 assert_eq!(triggered.len(), 1);
727 assert_eq!(triggered[0], "workflow-1");
728 }
729
730 #[test]
731 fn test_trigger_engine_multiple_workflows() {
732 let mut engine = TriggerEngine::new();
733 engine.add_trigger(
734 "wf-a",
735 TriggerType::FileArrival(FileArrivalTrigger::new("/watch", "*.mp4", 0, 0)),
736 );
737 engine.add_trigger(
738 "wf-b",
739 TriggerType::FileArrival(FileArrivalTrigger::new("/watch", "*.mp4", 0, 0)),
740 );
741
742 let triggered = engine.evaluate_file_arrival("/watch/test.mp4", 1);
743 assert_eq!(triggered.len(), 2);
744 }
745
746 #[test]
747 fn test_trigger_engine_no_match() {
748 let mut engine = TriggerEngine::new();
749 engine.add_trigger(
750 "wf-1",
751 TriggerType::FileArrival(FileArrivalTrigger::new("/watch", "*.mxf", 0, 0)),
752 );
753
754 let triggered = engine.evaluate_file_arrival("/watch/test.mp4", 1);
755 assert!(triggered.is_empty());
756 }
757
758 #[test]
759 fn test_trigger_engine_remove_workflow() {
760 let mut engine = TriggerEngine::new();
761 engine.add_trigger(
762 "wf-1",
763 TriggerType::FileArrival(FileArrivalTrigger::new("/watch", "*.mp4", 0, 0)),
764 );
765
766 engine.remove_workflow("wf-1");
767
768 let triggered = engine.evaluate_file_arrival("/watch/test.mp4", 1);
769 assert!(triggered.is_empty());
770 }
771
772 #[test]
773 fn test_trigger_condition_variants() {
774 let triggers = vec![TriggerType::ManualStart, TriggerType::ApiCall];
775 let _all = TriggerCondition::All(triggers.clone());
776 let _any = TriggerCondition::Any(triggers.clone());
777 let _none = TriggerCondition::None(triggers);
778 }
780
781 #[test]
782 fn test_glob_match_star_extension() {
783 assert!(glob_match("*.mp4", "video.mp4"));
784 assert!(glob_match("*.mp4", ".mp4"));
785 assert!(!glob_match("*.mp4", "video.mov"));
786 }
787
788 #[test]
789 fn test_glob_match_multiple_stars() {
790 assert!(glob_match("*_*_v2.*", "clip_cam1_v2.mxf"));
791 assert!(!glob_match("*_*_v2.*", "clip_cam1_v1.mxf"));
792 }
793
794 #[test]
799 fn test_webhook_trigger_basic_match() {
800 let trigger =
801 WebhookTrigger::new("/hooks/ingest").with_required_field("event", "ingest.ready");
802
803 let body = serde_json::json!({"event": "ingest.ready", "asset": "clip-001"});
804 assert!(trigger.body_matches(&body));
805 }
806
807 #[test]
808 fn test_webhook_trigger_field_mismatch() {
809 let trigger =
810 WebhookTrigger::new("/hooks/ingest").with_required_field("event", "ingest.ready");
811
812 let body = serde_json::json!({"event": "transcode.done"});
813 assert!(!trigger.body_matches(&body));
814 }
815
816 #[test]
817 fn test_webhook_trigger_missing_field() {
818 let trigger =
819 WebhookTrigger::new("/hooks/ingest").with_required_field("event", "ingest.ready");
820
821 let body = serde_json::json!({"other": "value"});
822 assert!(!trigger.body_matches(&body));
823 }
824
825 #[test]
826 fn test_webhook_trigger_no_secret_always_valid() {
827 let trigger = WebhookTrigger::new("/hooks/test");
828 assert!(trigger.verify_signature(b"payload", ""));
829 assert!(trigger.verify_signature(b"payload", "any_sig"));
830 }
831
832 #[test]
833 fn test_webhook_trigger_with_secret_valid() {
834 let trigger = WebhookTrigger::new("/hooks/test").with_secret("mysecret");
835 let payload = b"hello world";
836 let expected = hmac_sha256(b"mysecret", payload);
838 assert!(trigger.verify_signature(payload, &expected));
839 }
840
841 #[test]
842 fn test_webhook_trigger_with_secret_invalid() {
843 let trigger = WebhookTrigger::new("/hooks/test").with_secret("mysecret");
844 assert!(!trigger.verify_signature(b"payload", "wrong_signature"));
845 }
846
847 #[test]
848 fn test_webhook_trigger_builder_chain() {
849 let trigger = WebhookTrigger::new("/hooks/media")
850 .with_secret("s3cr3t")
851 .with_required_field("type", "video")
852 .with_description("Video ingest webhook");
853
854 assert_eq!(trigger.path, "/hooks/media");
855 assert!(trigger.secret.is_some());
856 assert_eq!(trigger.required_fields.len(), 1);
857 assert_eq!(trigger.description, "Video ingest webhook");
858 }
859
860 #[test]
865 fn test_webhook_router_basic_route() {
866 let mut router = WebhookRouter::new();
867 let trigger = WebhookTrigger::new("/hooks/ingest").with_required_field("event", "ready");
868 router.register("wf-ingest", trigger);
869
870 let body = serde_json::json!({"event": "ready"});
871 let body_bytes = serde_json::to_vec(&body).expect("serialize");
872 let triggered = router.route("/hooks/ingest", &body_bytes, &body, None);
873 assert_eq!(triggered.len(), 1);
874 assert_eq!(triggered[0], "wf-ingest");
875 }
876
877 #[test]
878 fn test_webhook_router_path_no_match() {
879 let mut router = WebhookRouter::new();
880 router.register("wf-1", WebhookTrigger::new("/hooks/ingest"));
881
882 let body = serde_json::json!({});
883 let body_bytes = serde_json::to_vec(&body).expect("serialize");
884 let triggered = router.route("/hooks/other", &body_bytes, &body, None);
885 assert!(triggered.is_empty());
886 }
887
888 #[test]
889 fn test_webhook_router_multiple_workflows() {
890 let mut router = WebhookRouter::new();
891 router.register("wf-a", WebhookTrigger::new("/hooks/event"));
892 router.register("wf-b", WebhookTrigger::new("/hooks/event"));
893
894 let body = serde_json::json!({});
895 let body_bytes = serde_json::to_vec(&body).expect("serialize");
896 let mut triggered = router.route("/hooks/event", &body_bytes, &body, None);
897 triggered.sort();
898 assert_eq!(triggered.len(), 2);
899 }
900
901 #[test]
902 fn test_webhook_router_remove() {
903 let mut router = WebhookRouter::new();
904 router.register("wf-1", WebhookTrigger::new("/hooks/test"));
905 router.remove("wf-1");
906
907 let body = serde_json::json!({});
908 let body_bytes = serde_json::to_vec(&body).expect("serialize");
909 let triggered = router.route("/hooks/test", &body_bytes, &body, None);
910 assert!(triggered.is_empty());
911 }
912
913 #[test]
914 fn test_webhook_router_trigger_count() {
915 let mut router = WebhookRouter::new();
916 router.register("wf-1", WebhookTrigger::new("/hooks/a"));
917 router.register("wf-1", WebhookTrigger::new("/hooks/b"));
918 router.register("wf-2", WebhookTrigger::new("/hooks/c"));
919 assert_eq!(router.trigger_count(), 3);
920 }
921
922 #[test]
923 fn test_sha256_known_value() {
924 let hash = sha256(b"");
926 let hex: String = hash.iter().fold(String::new(), |mut s, b| {
927 s.push_str(&format!("{b:02x}"));
928 s
929 });
930 assert_eq!(
931 hex,
932 "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
933 );
934 }
935
936 #[test]
937 fn test_hmac_sha256_non_empty() {
938 let mac = hmac_sha256(b"key", b"message");
940 assert_eq!(mac.len(), 64);
941 assert!(mac.chars().all(|c| c.is_ascii_hexdigit()));
942 }
943}
944
945#[derive(Debug, Clone)]
951pub struct WebhookPayload {
952 pub headers: HashMap<String, String>,
954 pub body: Vec<u8>,
956 pub timestamp: u64,
958 pub path: String,
960}
961
962impl WebhookPayload {
963 #[must_use]
965 pub fn new(
966 path: impl Into<String>,
967 headers: HashMap<String, String>,
968 body: Vec<u8>,
969 timestamp: u64,
970 ) -> Self {
971 Self {
972 headers,
973 body,
974 timestamp,
975 path: path.into(),
976 }
977 }
978
979 #[must_use]
983 pub fn header(&self, key: &str) -> Option<&str> {
984 let key_lower = key.to_ascii_lowercase();
985 self.headers
986 .iter()
987 .find(|(k, _)| k.to_ascii_lowercase() == key_lower)
988 .map(|(_, v)| v.as_str())
989 }
990
991 #[must_use]
993 pub fn body_str(&self) -> Option<&str> {
994 std::str::from_utf8(&self.body).ok()
995 }
996
997 pub fn body_json(&self) -> Result<serde_json::Value, serde_json::Error>
1003 where
1004 Self: Sized,
1005 {
1006 serde_json::from_slice(&self.body)
1007 }
1008}
1009
1010#[cfg(not(target_arch = "wasm32"))]
1026pub struct WebhookTriggerServer {
1027 bind_addr: String,
1029 router: std::sync::Arc<std::sync::RwLock<WebhookRouter>>,
1031 running: std::sync::Arc<std::sync::atomic::AtomicBool>,
1033}
1034
1035#[cfg(not(target_arch = "wasm32"))]
1036impl WebhookTriggerServer {
1037 #[must_use]
1043 pub fn new(bind_addr: impl Into<String>) -> Self {
1044 Self {
1045 bind_addr: bind_addr.into(),
1046 router: std::sync::Arc::new(std::sync::RwLock::new(WebhookRouter::new())),
1047 running: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
1048 }
1049 }
1050
1051 pub fn register_trigger(&self, workflow_id: impl Into<String>, trigger: WebhookTrigger) {
1053 if let Ok(mut r) = self.router.write() {
1054 r.register(workflow_id, trigger);
1055 }
1056 }
1057
1058 #[must_use]
1060 pub fn is_running(&self) -> bool {
1061 self.running.load(std::sync::atomic::Ordering::SeqCst)
1062 }
1063
1064 pub fn start(&self) -> Result<std::thread::JoinHandle<()>, std::io::Error> {
1074 use std::net::TcpListener;
1075 use std::sync::atomic::Ordering;
1076
1077 let listener = TcpListener::bind(&self.bind_addr)?;
1078 listener.set_nonblocking(true)?;
1081
1082 let router = std::sync::Arc::clone(&self.router);
1083 let running = std::sync::Arc::clone(&self.running);
1084 running.store(true, Ordering::SeqCst);
1085
1086 let handle = std::thread::spawn(move || {
1087 while running.load(Ordering::SeqCst) {
1088 match listener.accept() {
1089 Ok((stream, _addr)) => {
1090 let router_ref = std::sync::Arc::clone(&router);
1091 Self::handle_connection(stream, router_ref);
1095 }
1096 Err(ref e)
1097 if e.kind() == std::io::ErrorKind::WouldBlock
1098 || e.kind() == std::io::ErrorKind::TimedOut =>
1099 {
1100 std::thread::sleep(std::time::Duration::from_millis(1));
1102 }
1103 Err(_) => {
1104 break;
1106 }
1107 }
1108 }
1109 running.store(false, Ordering::SeqCst);
1110 });
1111
1112 Ok(handle)
1113 }
1114
1115 pub fn stop(&self) {
1118 self.running
1119 .store(false, std::sync::atomic::Ordering::SeqCst);
1120 }
1121
1122 pub fn handle_connection(
1127 mut stream: std::net::TcpStream,
1128 router: std::sync::Arc<std::sync::RwLock<WebhookRouter>>,
1129 ) -> Vec<String> {
1130 use std::io::{BufRead, BufReader, Read, Write};
1131
1132 let _ = stream.set_nonblocking(false);
1135
1136 let mut reader = BufReader::new(&mut stream);
1138
1139 let mut request_line = String::new();
1140 if reader.read_line(&mut request_line).is_err() {
1141 let _ = stream.write_all(b"HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\n\r\n");
1142 return Vec::new();
1143 }
1144
1145 let parts: Vec<&str> = request_line.trim().splitn(3, ' ').collect();
1147 if parts.len() < 2 {
1148 let _ = stream.write_all(b"HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\n\r\n");
1149 return Vec::new();
1150 }
1151 let path = parts[1].to_string();
1152
1153 let mut headers: HashMap<String, String> = HashMap::new();
1155 loop {
1156 let mut line = String::new();
1157 match reader.read_line(&mut line) {
1158 Ok(0) | Err(_) => break,
1159 Ok(_) => {
1160 let trimmed = line.trim_end_matches(['\r', '\n']);
1161 if trimmed.is_empty() {
1162 break; }
1164 if let Some(colon_pos) = trimmed.find(':') {
1165 let key = trimmed[..colon_pos].trim().to_ascii_lowercase();
1166 let value = trimmed[colon_pos + 1..].trim().to_string();
1167 headers.insert(key, value);
1168 }
1169 }
1170 }
1171 }
1172
1173 let content_length: usize = headers
1175 .get("content-length")
1176 .and_then(|v| v.parse().ok())
1177 .unwrap_or(0);
1178
1179 let mut body = vec![0u8; content_length];
1180 if content_length > 0 {
1181 if reader.read_exact(&mut body).is_err() {
1182 let _ = stream.write_all(b"HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\n\r\n");
1183 return Vec::new();
1184 }
1185 }
1186
1187 let signature = headers.get("x-hub-signature-256").map(String::as_str);
1189
1190 let body_json: serde_json::Value =
1192 serde_json::from_slice(&body).unwrap_or_else(|_| serde_json::json!({}));
1193
1194 let triggered = router
1196 .read()
1197 .map(|r| r.route(&path, &body, &body_json, signature))
1198 .unwrap_or_default();
1199
1200 let status = if triggered.is_empty() {
1202 "404 Not Found"
1203 } else {
1204 "200 OK"
1205 };
1206 let resp = format!("HTTP/1.1 {status}\r\nContent-Length: 0\r\n\r\n");
1207 let _ = stream.write_all(resp.as_bytes());
1208
1209 triggered
1210 }
1211
1212 pub fn local_addr(&self) -> Result<std::net::SocketAddr, std::io::Error> {
1218 std::net::TcpListener::bind(&self.bind_addr).and_then(|l| l.local_addr())
1222 }
1223}
1224
1225#[cfg(test)]
1230mod server_tests {
1231 use super::*;
1232
1233 #[test]
1236 fn test_webhook_payload_construction() {
1237 let mut hdrs = HashMap::new();
1238 hdrs.insert("content-type".to_string(), "application/json".to_string());
1239 let body = b"{\"event\":\"push\"}".to_vec();
1240 let payload = WebhookPayload::new("/hooks/push", hdrs, body.clone(), 1_700_000_000_000);
1241
1242 assert_eq!(payload.path, "/hooks/push");
1243 assert_eq!(payload.body, body);
1244 assert_eq!(payload.timestamp, 1_700_000_000_000);
1245 }
1246
1247 #[test]
1248 fn test_webhook_payload_header_case_insensitive() {
1249 let mut hdrs = HashMap::new();
1250 hdrs.insert("X-Hub-Signature-256".to_string(), "abc123".to_string());
1251 let payload = WebhookPayload::new("/", hdrs, Vec::new(), 0);
1252
1253 assert_eq!(payload.header("x-hub-signature-256"), Some("abc123"));
1254 assert_eq!(payload.header("X-HUB-SIGNATURE-256"), Some("abc123"));
1255 assert_eq!(payload.header("X-Hub-Signature-256"), Some("abc123"));
1256 assert!(payload.header("missing-header").is_none());
1257 }
1258
1259 #[test]
1260 fn test_webhook_payload_body_str_valid_utf8() {
1261 let body = b"hello world".to_vec();
1262 let payload = WebhookPayload::new("/", HashMap::new(), body, 0);
1263 assert_eq!(payload.body_str(), Some("hello world"));
1264 }
1265
1266 #[test]
1267 fn test_webhook_payload_body_str_invalid_utf8() {
1268 let body = vec![0xFF, 0xFE]; let payload = WebhookPayload::new("/", HashMap::new(), body, 0);
1270 assert!(payload.body_str().is_none());
1271 }
1272
1273 #[test]
1274 fn test_webhook_payload_body_json_valid() {
1275 let body = b"{\"key\":42}".to_vec();
1276 let payload = WebhookPayload::new("/", HashMap::new(), body, 0);
1277 let json = payload.body_json().expect("valid JSON");
1278 assert_eq!(json["key"], 42);
1279 }
1280
1281 #[test]
1282 fn test_webhook_payload_body_json_invalid() {
1283 let body = b"not json".to_vec();
1284 let payload = WebhookPayload::new("/", HashMap::new(), body, 0);
1285 assert!(payload.body_json().is_err());
1286 }
1287
1288 #[test]
1289 fn test_webhook_payload_empty_body() {
1290 let payload = WebhookPayload::new("/", HashMap::new(), Vec::new(), 0);
1291 assert_eq!(payload.body_str(), Some(""));
1292 assert!(payload.body_json().is_err()); }
1294
1295 #[cfg(not(target_arch = "wasm32"))]
1298 #[test]
1299 fn test_server_new_not_running() {
1300 let server = WebhookTriggerServer::new("127.0.0.1:0");
1301 assert!(!server.is_running());
1302 }
1303
1304 #[cfg(not(target_arch = "wasm32"))]
1305 #[test]
1306 fn test_server_register_trigger() {
1307 let server = WebhookTriggerServer::new("127.0.0.1:0");
1308 server.register_trigger("wf-1", WebhookTrigger::new("/hooks/test"));
1309 let router_guard = server.router.read().expect("read lock");
1311 assert_eq!(router_guard.trigger_count(), 1);
1312 }
1313
1314 #[cfg(not(target_arch = "wasm32"))]
1315 #[test]
1316 fn test_server_register_multiple_triggers() {
1317 let server = WebhookTriggerServer::new("127.0.0.1:0");
1318 server.register_trigger("wf-1", WebhookTrigger::new("/hooks/a"));
1319 server.register_trigger("wf-1", WebhookTrigger::new("/hooks/b"));
1320 server.register_trigger("wf-2", WebhookTrigger::new("/hooks/c"));
1321 let router_guard = server.router.read().expect("read lock");
1322 assert_eq!(router_guard.trigger_count(), 3);
1323 }
1324
1325 #[cfg(not(target_arch = "wasm32"))]
1328 #[test]
1329 fn test_server_start_stop() {
1330 use std::net::TcpListener;
1331
1332 let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
1333 let port = listener.local_addr().expect("local addr").port();
1334 drop(listener);
1335
1336 let server = WebhookTriggerServer::new(format!("127.0.0.1:{port}"));
1337 server.register_trigger("wf-webhook", WebhookTrigger::new("/hooks/deploy"));
1338
1339 let handle = server.start().expect("start server");
1340 assert!(server.is_running());
1341
1342 server.stop();
1343 std::thread::sleep(std::time::Duration::from_millis(50));
1345 let _ = handle.join();
1346 assert!(!server.is_running());
1347 }
1348
1349 #[cfg(not(target_arch = "wasm32"))]
1350 #[test]
1351 fn test_server_receives_post_triggers_workflow() {
1352 use std::io::Write;
1353 use std::net::{TcpListener, TcpStream};
1354
1355 let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
1357 let port = listener.local_addr().expect("local addr").port();
1358 drop(listener);
1359
1360 let server = WebhookTriggerServer::new(format!("127.0.0.1:{port}"));
1361 server.register_trigger("my-workflow", WebhookTrigger::new("/hooks/push"));
1362
1363 let _handle = server.start().expect("start server");
1364 std::thread::sleep(std::time::Duration::from_millis(30));
1366
1367 let body = b"{}";
1369 let request = format!(
1370 "POST /hooks/push HTTP/1.1\r\nHost: localhost\r\nContent-Length: {}\r\n\r\n",
1371 body.len()
1372 );
1373
1374 let mut conn = TcpStream::connect(format!("127.0.0.1:{port}")).expect("connect");
1375 conn.write_all(request.as_bytes()).expect("write request");
1376 conn.write_all(body).expect("write body");
1377
1378 let mut resp_buf = [0u8; 256];
1380 let n = {
1381 use std::io::Read;
1382 conn.read(&mut resp_buf).unwrap_or(0)
1383 };
1384 let resp_str = std::str::from_utf8(&resp_buf[..n]).unwrap_or("");
1385 assert!(
1386 resp_str.starts_with("HTTP/1.1 200"),
1387 "expected 200, got: {resp_str}"
1388 );
1389
1390 server.stop();
1391 }
1392
1393 #[cfg(not(target_arch = "wasm32"))]
1394 #[test]
1395 fn test_server_wrong_path_returns_404() {
1396 use std::io::Write;
1397 use std::net::{TcpListener, TcpStream};
1398
1399 let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
1400 let port = listener.local_addr().expect("local addr").port();
1401 drop(listener);
1402
1403 let server = WebhookTriggerServer::new(format!("127.0.0.1:{port}"));
1404 server.register_trigger("wf-x", WebhookTrigger::new("/hooks/correct"));
1405
1406 let _handle = server.start().expect("start");
1407 std::thread::sleep(std::time::Duration::from_millis(30));
1408
1409 let body = b"{}";
1410 let request = format!(
1411 "POST /hooks/wrong HTTP/1.1\r\nHost: localhost\r\nContent-Length: {}\r\n\r\n",
1412 body.len()
1413 );
1414 let mut conn = TcpStream::connect(format!("127.0.0.1:{port}")).expect("connect");
1415 conn.write_all(request.as_bytes()).expect("write");
1416 conn.write_all(body).expect("write body");
1417
1418 let mut buf = [0u8; 256];
1419 let n = {
1420 use std::io::Read;
1421 conn.read(&mut buf).unwrap_or(0)
1422 };
1423 let resp = std::str::from_utf8(&buf[..n]).unwrap_or("");
1424 assert!(
1425 resp.starts_with("HTTP/1.1 404"),
1426 "expected 404, got: {resp}"
1427 );
1428
1429 server.stop();
1430 }
1431
1432 #[cfg(not(target_arch = "wasm32"))]
1433 #[test]
1434 fn test_server_signature_verification_accepted() {
1435 use std::io::Write;
1436 use std::net::{TcpListener, TcpStream};
1437
1438 let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
1439 let port = listener.local_addr().expect("local addr").port();
1440 drop(listener);
1441
1442 let secret = "s3cr3t";
1443 let server = WebhookTriggerServer::new(format!("127.0.0.1:{port}"));
1444 server.register_trigger(
1445 "secure-wf",
1446 WebhookTrigger::new("/hooks/secure").with_secret(secret),
1447 );
1448
1449 let _handle = server.start().expect("start");
1450 std::thread::sleep(std::time::Duration::from_millis(30));
1451
1452 let body = b"{}";
1453 let sig = {
1456 let _dummy_trigger = WebhookTrigger::new("/").with_secret(secret);
1458 use crate::webhook::{WebhookConfig, WebhookNotifier};
1462 let notifier = WebhookNotifier::new(WebhookConfig {
1463 url: String::new(),
1464 secret: Some(secret.to_string()),
1465 events: Vec::new(),
1466 max_retries: 0,
1467 timeout_ms: 0,
1468 });
1469 notifier
1470 .compute_signature(std::str::from_utf8(body).expect("utf8"))
1471 .expect("sig")
1472 };
1473
1474 let request = format!(
1475 "POST /hooks/secure HTTP/1.1\r\nHost: localhost\r\nContent-Length: {}\r\nX-Hub-Signature-256: {}\r\n\r\n",
1476 body.len(),
1477 sig
1478 );
1479 let mut conn = TcpStream::connect(format!("127.0.0.1:{port}")).expect("connect");
1480 conn.write_all(request.as_bytes()).expect("write");
1481 conn.write_all(body).expect("write body");
1482
1483 let mut buf = [0u8; 256];
1484 let n = {
1485 use std::io::Read;
1486 conn.read(&mut buf).unwrap_or(0)
1487 };
1488 let resp = std::str::from_utf8(&buf[..n]).unwrap_or("");
1489 assert!(
1490 resp.starts_with("HTTP/1.1 200"),
1491 "expected 200 with valid sig, got: {resp}"
1492 );
1493
1494 server.stop();
1495 }
1496
1497 #[cfg(not(target_arch = "wasm32"))]
1498 #[test]
1499 fn test_server_bad_signature_returns_404() {
1500 use std::io::Write;
1501 use std::net::{TcpListener, TcpStream};
1502
1503 let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
1504 let port = listener.local_addr().expect("local addr").port();
1505 drop(listener);
1506
1507 let server = WebhookTriggerServer::new(format!("127.0.0.1:{port}"));
1508 server.register_trigger(
1509 "secure-wf",
1510 WebhookTrigger::new("/hooks/secure").with_secret("correct-secret"),
1511 );
1512
1513 let _handle = server.start().expect("start");
1514 std::thread::sleep(std::time::Duration::from_millis(30));
1515
1516 let body = b"{}";
1517 let bad_sig = "a".repeat(64); let request = format!(
1519 "POST /hooks/secure HTTP/1.1\r\nHost: localhost\r\nContent-Length: {}\r\nX-Hub-Signature-256: {bad_sig}\r\n\r\n",
1520 body.len()
1521 );
1522 let mut conn = TcpStream::connect(format!("127.0.0.1:{port}")).expect("connect");
1523 conn.write_all(request.as_bytes()).expect("write");
1524 conn.write_all(body).expect("write body");
1525
1526 let mut buf = [0u8; 256];
1527 let n = {
1528 use std::io::Read;
1529 conn.read(&mut buf).unwrap_or(0)
1530 };
1531 let resp = std::str::from_utf8(&buf[..n]).unwrap_or("");
1532 assert!(
1533 resp.starts_with("HTTP/1.1 404"),
1534 "bad sig should get 404, got: {resp}"
1535 );
1536
1537 server.stop();
1538 }
1539
1540 #[cfg(not(target_arch = "wasm32"))]
1541 #[test]
1542 fn test_server_body_field_matching() {
1543 use std::io::Write;
1544 use std::net::{TcpListener, TcpStream};
1545
1546 let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
1547 let port = listener.local_addr().expect("local addr").port();
1548 drop(listener);
1549
1550 let server = WebhookTriggerServer::new(format!("127.0.0.1:{port}"));
1551 server.register_trigger(
1552 "wf-prod",
1553 WebhookTrigger::new("/hooks/deploy").with_required_field("environment", "production"),
1554 );
1555
1556 let _handle = server.start().expect("start");
1557 std::thread::sleep(std::time::Duration::from_millis(30));
1558
1559 let body = br#"{"environment":"production"}"#;
1562 let mut full_request = format!(
1563 "POST /hooks/deploy HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n",
1564 body.len()
1565 ).into_bytes();
1566 full_request.extend_from_slice(body);
1567
1568 let mut conn = TcpStream::connect(format!("127.0.0.1:{port}")).expect("connect");
1569 conn.write_all(&full_request).expect("write full request");
1570 conn.shutdown(std::net::Shutdown::Write).ok();
1572
1573 let mut buf = [0u8; 256];
1574 let n = {
1575 use std::io::Read;
1576 conn.read(&mut buf).unwrap_or(0)
1577 };
1578 let resp = std::str::from_utf8(&buf[..n]).unwrap_or("");
1579 assert!(
1580 resp.starts_with("HTTP/1.1 200"),
1581 "matching body should trigger: {resp}"
1582 );
1583
1584 server.stop();
1585 }
1586}