Skip to main content

oximedia_workflow/
webhook_trigger.rs

1//! HTTP webhook trigger system for OxiMedia workflows.
2//!
3//! This module provides a self-contained webhook trigger registry that maps
4//! incoming HTTP requests to workflow IDs. It is transport-agnostic: callers
5//! construct a [`WebhookPayload`] from whatever HTTP framework they use and
6//! pass it to [`WebhookTriggerRegistry::match_trigger`].
7//!
8//! # Signature verification
9//!
10//! When a [`WebhookTrigger`] is configured with a `secret_token`, the registry
11//! validates the `X-Signature` request header against a deterministic XOR-based
12//! MAC of the raw body. The MAC is **not** cryptographically secure (it is a
13//! lightweight stub for integration testing); production deployments should use
14//! the full HMAC-SHA256 implementation in [`crate::triggers`].
15//!
16//! # Example
17//!
18//! ```rust
19//! use oximedia_workflow::webhook_trigger::{
20//!     WebhookTrigger, WebhookTriggerRegistry, WebhookPayload,
21//! };
22//!
23//! let mut registry = WebhookTriggerRegistry::new();
24//! registry.add_trigger(WebhookTrigger {
25//!     id: "t1".to_string(),
26//!     path: "/webhooks/ingest".to_string(),
27//!     workflow_id: "wf-ingest".to_string(),
28//!     secret_token: None,
29//! });
30//!
31//! let payload = WebhookPayload {
32//!     method: "POST".to_string(),
33//!     path: "/webhooks/ingest".to_string(),
34//!     body: "{}".to_string(),
35//!     headers: vec![],
36//! };
37//!
38//! let trigger = registry.match_trigger(&payload);
39//! assert!(trigger.is_some());
40//! assert_eq!(trigger.unwrap().workflow_id, "wf-ingest");
41//! ```
42
43#![allow(dead_code)]
44
45// ---------------------------------------------------------------------------
46// WebhookTrigger
47// ---------------------------------------------------------------------------
48
49/// A registered webhook trigger that binds an HTTP path to a workflow.
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct WebhookTrigger {
52    /// Unique identifier for this trigger.
53    pub id: String,
54    /// HTTP path that activates the trigger (e.g. `"/webhooks/ingest-ready"`).
55    pub path: String,
56    /// ID of the workflow to start when this trigger fires.
57    pub workflow_id: String,
58    /// Optional shared secret used to validate `X-Signature` headers.
59    ///
60    /// When set the header value must match `sha256=<xor_mac(body, secret)>`.
61    pub secret_token: Option<String>,
62}
63
64impl WebhookTrigger {
65    /// Create a new trigger without a secret.
66    #[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    /// Attach a shared secret to this trigger.
81    #[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// ---------------------------------------------------------------------------
89// WebhookPayload
90// ---------------------------------------------------------------------------
91
92/// Represents an incoming HTTP request delivered to a webhook endpoint.
93#[derive(Debug, Clone)]
94pub struct WebhookPayload {
95    /// HTTP method (e.g. `"POST"`).
96    pub method: String,
97    /// URL path of the request.
98    pub path: String,
99    /// Raw request body as a UTF-8 string.
100    pub body: String,
101    /// Request headers as `(name, value)` pairs.  Header names should be
102    /// lower-cased for case-insensitive comparison.
103    pub headers: Vec<(String, String)>,
104}
105
106impl WebhookPayload {
107    /// Look up a header value by name (case-insensitive).
108    #[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// ---------------------------------------------------------------------------
119// WebhookTriggerRegistry
120// ---------------------------------------------------------------------------
121
122/// In-process registry that matches incoming [`WebhookPayload`]s to registered
123/// [`WebhookTrigger`]s.
124#[derive(Debug, Default)]
125pub struct WebhookTriggerRegistry {
126    /// All registered triggers, in insertion order.
127    pub triggers: Vec<WebhookTrigger>,
128}
129
130impl WebhookTriggerRegistry {
131    /// Create an empty registry.
132    #[must_use]
133    pub fn new() -> Self {
134        Self::default()
135    }
136
137    /// Register a new trigger.  If a trigger with the same `id` already exists
138    /// it is replaced.
139    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    /// Remove the trigger with the given id, returning it if present.
148    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    /// Return the first trigger whose `path` exactly matches
157    /// `payload.path`.
158    ///
159    /// If the matching trigger has a `secret_token`, the `X-Signature` header
160    /// of the payload is also validated via [`validate_signature`].  A trigger
161    /// whose signature check **fails** is skipped (the next matching trigger is
162    /// tried instead).
163    #[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 a secret is configured, validate the signature header.
170            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    /// Return the number of registered triggers.
181    #[must_use]
182    pub fn len(&self) -> usize {
183        self.triggers.len()
184    }
185
186    /// Return `true` when no triggers are registered.
187    #[must_use]
188    pub fn is_empty(&self) -> bool {
189        self.triggers.is_empty()
190    }
191}
192
193// ---------------------------------------------------------------------------
194// XOR-based MAC (stub — not cryptographically secure)
195// ---------------------------------------------------------------------------
196
197/// Compute a lightweight 64-bit XOR-based MAC of `body` keyed with `secret`.
198///
199/// The algorithm folds each byte of `body` into a 64-bit accumulator using
200/// XOR and a FNV-inspired mixing step, then XORs in bytes of the key cyclically.
201/// This is **not** a secure MAC; it is a deterministic stub for use in tests
202/// and local development.
203#[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; // FNV-1a offset basis
213
214    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        // FNV-like mixing
218        acc = acc.wrapping_mul(0x0000_0100_0000_01b3);
219    }
220    acc
221}
222
223/// Validate the `X-Signature` header of a [`WebhookPayload`] against a secret.
224///
225/// The expected header format is `sha256=<hex-encoded xor_mac>`.
226///
227/// Returns `true` when:
228/// - The header is present and well-formed, **and**
229/// - The computed MAC of `payload.body` with `secret` matches the header value.
230///
231/// Returns `false` in all other cases (missing header, bad format, wrong MAC).
232#[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    // Constant-time comparison (lengths equal → byte XOR fold).
248    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// ===========================================================================
261// SimpleWebhookTrigger — URL-centric fire-and-report API
262// ===========================================================================
263
264/// A lightweight webhook trigger keyed only by destination URL.
265///
266/// Unlike [`WebhookTrigger`] (which is path-routed), `SimpleWebhookTrigger`
267/// holds the full URL and exposes a single [`fire`](Self::fire) method that
268/// returns a human-readable report string describing the event it would
269/// dispatch.  No actual HTTP call is made; the implementation is deliberately
270/// synchronous and allocation-free for use in tests and offline pipelines.
271///
272/// # Example
273///
274/// ```rust
275/// use oximedia_workflow::webhook_trigger::SimpleWebhookTrigger;
276///
277/// let trigger = SimpleWebhookTrigger::new("https://example.com/hooks/ingest");
278/// let report = trigger.fire("job.complete");
279/// assert!(report.contains("job.complete"));
280/// assert!(report.contains("example.com"));
281/// ```
282#[derive(Debug, Clone)]
283pub struct SimpleWebhookTrigger {
284    /// Destination URL for the webhook.
285    pub url: String,
286}
287
288impl SimpleWebhookTrigger {
289    /// Create a new simple webhook trigger for the given URL.
290    #[must_use]
291    pub fn new(url: impl Into<String>) -> Self {
292        Self { url: url.into() }
293    }
294
295    /// Simulate firing a webhook event.
296    ///
297    /// Returns a report string: `"WEBHOOK {url} event={event}"`.
298    /// No real HTTP request is made.
299    #[must_use]
300    pub fn fire(&self, event: &str) -> String {
301        format!("WEBHOOK {} event={event}", self.url)
302    }
303
304    /// Return the target URL.
305    #[must_use]
306    pub fn url(&self) -> &str {
307        &self.url
308    }
309}
310
311// ===========================================================================
312// Tests
313// ===========================================================================
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318
319    // ------------------------------------------------------------------
320    // xor_mac
321    // ------------------------------------------------------------------
322
323    #[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    // ------------------------------------------------------------------
355    // validate_signature
356    // ------------------------------------------------------------------
357
358    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    // ------------------------------------------------------------------
404    // WebhookPayload::header
405    // ------------------------------------------------------------------
406
407    #[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    // ------------------------------------------------------------------
421    // WebhookTriggerRegistry
422    // ------------------------------------------------------------------
423
424    #[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}