Skip to main content

shaperail_runtime/events/
webhook.rs

1use std::time::Instant;
2
3use hmac::{Hmac, Mac};
4use serde::{Deserialize, Serialize};
5use sha2::Sha256;
6use shaperail_core::ShaperailError;
7
8/// Outbound webhook dispatcher.
9///
10/// Delivers event payloads to external URLs with HMAC-SHA256 signature headers.
11/// Signature format: `X-Shaperail-Signature: sha256=<hex-digest>`
12#[derive(Clone)]
13pub struct WebhookDispatcher {
14    secret: String,
15    timeout_secs: u64,
16}
17
18/// Result of a webhook delivery attempt.
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct DeliveryResult {
21    /// HTTP status code (0 if connection failed).
22    pub status_code: u16,
23    /// Whether delivery was successful (2xx status).
24    pub success: bool,
25    /// Latency in milliseconds.
26    pub latency_ms: u64,
27    /// Error message if delivery failed.
28    pub error: Option<String>,
29}
30
31impl WebhookDispatcher {
32    /// Creates a new webhook dispatcher.
33    pub fn new(secret: String, timeout_secs: u64) -> Self {
34        Self {
35            secret,
36            timeout_secs,
37        }
38    }
39
40    /// Creates a dispatcher from environment configuration.
41    ///
42    /// Reads the secret from the specified env var (defaults to WEBHOOK_SECRET).
43    pub fn from_env(secret_env: &str, timeout_secs: u64) -> Result<Self, ShaperailError> {
44        let secret = std::env::var(secret_env).map_err(|_| {
45            ShaperailError::Internal(format!("Webhook secret env var '{secret_env}' not set"))
46        })?;
47        Ok(Self::new(secret, timeout_secs))
48    }
49
50    /// Computes the HMAC-SHA256 signature for a payload.
51    pub fn sign(&self, body: &[u8]) -> String {
52        compute_hmac_signature(body, self.secret.as_bytes())
53    }
54
55    /// Returns the configured timeout in seconds.
56    pub fn timeout_secs(&self) -> u64 {
57        self.timeout_secs
58    }
59
60    /// Delivers a webhook payload to the given URL.
61    ///
62    /// This is a "fire" method that performs the actual HTTP POST.
63    /// In production, this is called from a job handler with retry support.
64    ///
65    /// Note: This uses a minimal HTTP client built on `actix_web::client` or
66    /// raw TCP. For the milestone we track timing and return delivery results
67    /// without making actual HTTP calls — the job handler integration handles
68    /// the real delivery path.
69    pub fn build_delivery_request(
70        &self,
71        url: &str,
72        payload: &serde_json::Value,
73    ) -> Result<WebhookRequest, ShaperailError> {
74        let body = serde_json::to_vec(payload).map_err(|e| {
75            ShaperailError::Internal(format!("Failed to serialize webhook body: {e}"))
76        })?;
77        let signature = self.sign(&body);
78
79        Ok(WebhookRequest {
80            url: url.to_string(),
81            body,
82            signature,
83            timeout_secs: self.timeout_secs,
84        })
85    }
86}
87
88/// A prepared webhook request ready for delivery.
89#[derive(Debug, Clone)]
90pub struct WebhookRequest {
91    /// Target URL.
92    pub url: String,
93    /// JSON body bytes.
94    pub body: Vec<u8>,
95    /// HMAC-SHA256 signature (hex-encoded).
96    pub signature: String,
97    /// HTTP timeout in seconds.
98    pub timeout_secs: u64,
99}
100
101impl WebhookRequest {
102    /// Returns the `X-Shaperail-Signature` header value.
103    pub fn signature_header(&self) -> String {
104        format!("sha256={}", self.signature)
105    }
106
107    /// Simulates delivery and returns a result (for testing without actual HTTP).
108    ///
109    /// In production, the job handler uses an HTTP client to actually POST.
110    pub fn simulate_delivery(&self, status_code: u16) -> DeliveryResult {
111        let start = Instant::now();
112        let latency_ms = start.elapsed().as_millis() as u64;
113
114        DeliveryResult {
115            status_code,
116            success: (200..300).contains(&status_code),
117            latency_ms,
118            error: if (200..300).contains(&status_code) {
119                None
120            } else {
121                Some(format!("HTTP {status_code}"))
122            },
123        }
124    }
125}
126
127/// Computes an HMAC-SHA256 signature over a body using the given secret.
128///
129/// Returns the hex-encoded digest.
130pub fn compute_hmac_signature(body: &[u8], secret: &[u8]) -> String {
131    // HMAC-SHA256 accepts keys of any size, so this never fails in practice.
132    // We handle the error branch defensively to satisfy the no-unwrap rule.
133    let Ok(mut mac) = Hmac::<Sha256>::new_from_slice(secret) else {
134        return String::new();
135    };
136    mac.update(body);
137    let result = mac.finalize();
138    hex::encode(result.into_bytes())
139}
140
141/// Verifies an HMAC-SHA256 signature.
142///
143/// `signature` should be the hex-encoded digest (without "sha256=" prefix).
144pub fn verify_hmac_signature(body: &[u8], secret: &[u8], signature: &str) -> bool {
145    let expected = compute_hmac_signature(body, secret);
146    // Constant-time comparison to prevent timing attacks
147    constant_time_eq(expected.as_bytes(), signature.as_bytes())
148}
149
150/// Constant-time byte comparison to prevent timing attacks.
151fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
152    if a.len() != b.len() {
153        return false;
154    }
155    let mut diff = 0u8;
156    for (x, y) in a.iter().zip(b.iter()) {
157        diff |= x ^ y;
158    }
159    diff == 0
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165
166    #[test]
167    fn sign_and_verify() {
168        let secret = b"test-secret";
169        let body = b"hello world";
170        let sig = compute_hmac_signature(body, secret);
171        assert!(verify_hmac_signature(body, secret, &sig));
172    }
173
174    #[test]
175    fn wrong_secret_fails() {
176        let body = b"hello world";
177        let sig = compute_hmac_signature(body, b"correct-secret");
178        assert!(!verify_hmac_signature(body, b"wrong-secret", &sig));
179    }
180
181    #[test]
182    fn tampered_body_fails() {
183        let secret = b"test-secret";
184        let sig = compute_hmac_signature(b"original", secret);
185        assert!(!verify_hmac_signature(b"tampered", secret, &sig));
186    }
187
188    #[test]
189    fn webhook_request_signature_header() {
190        let dispatcher = WebhookDispatcher::new("my-secret".to_string(), 30);
191        let payload = serde_json::json!({"event": "test"});
192        let req = dispatcher
193            .build_delivery_request("https://example.com/hook", &payload)
194            .unwrap();
195        assert!(req.signature_header().starts_with("sha256="));
196    }
197
198    #[test]
199    fn delivery_result_success() {
200        let dispatcher = WebhookDispatcher::new("secret".to_string(), 30);
201        let payload = serde_json::json!({"event": "test"});
202        let req = dispatcher
203            .build_delivery_request("https://example.com", &payload)
204            .unwrap();
205        let result = req.simulate_delivery(200);
206        assert!(result.success);
207        assert!(result.error.is_none());
208    }
209
210    #[test]
211    fn delivery_result_failure() {
212        let dispatcher = WebhookDispatcher::new("secret".to_string(), 30);
213        let payload = serde_json::json!({"event": "test"});
214        let req = dispatcher
215            .build_delivery_request("https://example.com", &payload)
216            .unwrap();
217        let result = req.simulate_delivery(500);
218        assert!(!result.success);
219        assert_eq!(result.error.as_deref(), Some("HTTP 500"));
220    }
221
222    #[test]
223    fn constant_time_eq_works() {
224        assert!(constant_time_eq(b"hello", b"hello"));
225        assert!(!constant_time_eq(b"hello", b"world"));
226        assert!(!constant_time_eq(b"hello", b"hell"));
227    }
228
229    #[test]
230    fn dispatcher_sign_deterministic() {
231        let dispatcher = WebhookDispatcher::new("secret".to_string(), 30);
232        let body = b"test body";
233        let sig1 = dispatcher.sign(body);
234        let sig2 = dispatcher.sign(body);
235        assert_eq!(sig1, sig2);
236    }
237}