shaperail_runtime/events/
webhook.rs1use std::time::Instant;
2
3use hmac::{Hmac, Mac};
4use serde::{Deserialize, Serialize};
5use sha2::Sha256;
6use shaperail_core::ShaperailError;
7
8#[derive(Clone)]
13pub struct WebhookDispatcher {
14 secret: String,
15 timeout_secs: u64,
16}
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct DeliveryResult {
21 pub status_code: u16,
23 pub success: bool,
25 pub latency_ms: u64,
27 pub error: Option<String>,
29}
30
31impl WebhookDispatcher {
32 pub fn new(secret: String, timeout_secs: u64) -> Self {
34 Self {
35 secret,
36 timeout_secs,
37 }
38 }
39
40 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 pub fn sign(&self, body: &[u8]) -> String {
52 compute_hmac_signature(body, self.secret.as_bytes())
53 }
54
55 pub fn timeout_secs(&self) -> u64 {
57 self.timeout_secs
58 }
59
60 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#[derive(Debug, Clone)]
90pub struct WebhookRequest {
91 pub url: String,
93 pub body: Vec<u8>,
95 pub signature: String,
97 pub timeout_secs: u64,
99}
100
101impl WebhookRequest {
102 pub fn signature_header(&self) -> String {
104 format!("sha256={}", self.signature)
105 }
106
107 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
127pub fn compute_hmac_signature(body: &[u8], secret: &[u8]) -> String {
131 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
141pub fn verify_hmac_signature(body: &[u8], secret: &[u8], signature: &str) -> bool {
145 let expected = compute_hmac_signature(body, secret);
146 constant_time_eq(expected.as_bytes(), signature.as_bytes())
148}
149
150fn 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}