1use hmac::{Hmac, Mac};
5use serde::{Deserialize, Serialize};
6use sha2::Sha256;
7
8use crate::error::SchedulerError;
9
10type HmacSha256 = Hmac<Sha256>;
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct WebhookEndpoint {
15 pub path: String,
17 #[serde(default, skip_serializing_if = "Option::is_none")]
19 pub secret: Option<String>,
20 pub handlers: Vec<WebhookHandler>,
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct WebhookHandler {
27 #[serde(default, skip_serializing_if = "Option::is_none")]
30 pub event_type: Option<String>,
31 pub action: String,
33}
34
35#[derive(Debug, Clone)]
37pub struct WebhookRequest {
38 pub path: String,
40 pub body: Vec<u8>,
42 pub signature: Option<String>,
44 pub event_type: Option<String>,
46}
47
48#[derive(Debug, Clone)]
50pub struct WebhookResult {
51 pub actions: Vec<String>,
53 pub verified: bool,
55}
56
57impl WebhookEndpoint {
58 pub fn new(path: impl Into<String>) -> Self {
60 Self {
61 path: path.into(),
62 secret: None,
63 handlers: Vec::new(),
64 }
65 }
66
67 pub fn with_secret(mut self, secret: impl Into<String>) -> Self {
69 self.secret = Some(secret.into());
70 self
71 }
72
73 pub fn with_handler(mut self, handler: WebhookHandler) -> Self {
75 self.handlers.push(handler);
76 self
77 }
78
79 pub fn verify_signature(
81 &self,
82 body: &[u8],
83 signature: Option<&str>,
84 ) -> Result<bool, SchedulerError> {
85 match &self.secret {
86 None => Ok(true), Some(secret) => {
88 let sig = signature.ok_or_else(|| SchedulerError::WebhookVerificationFailed {
89 message: "Missing signature header".to_string(),
90 })?;
91
92 let hex_sig = sig.strip_prefix("sha256=").unwrap_or(sig);
94
95 let expected_bytes = hex::decode(hex_sig).map_err(|e| {
96 SchedulerError::WebhookVerificationFailed {
97 message: format!("Invalid hex signature: {}", e),
98 }
99 })?;
100
101 let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).map_err(|e| {
102 SchedulerError::WebhookVerificationFailed {
103 message: format!("HMAC error: {}", e),
104 }
105 })?;
106 mac.update(body);
107
108 match mac.verify_slice(&expected_bytes) {
109 Ok(_) => Ok(true),
110 Err(_) => Ok(false),
111 }
112 }
113 }
114 }
115
116 pub fn process(&self, request: &WebhookRequest) -> Result<WebhookResult, SchedulerError> {
118 let verified = self.verify_signature(&request.body, request.signature.as_deref())?;
119
120 if !verified {
121 return Err(SchedulerError::WebhookVerificationFailed {
122 message: "Signature mismatch".to_string(),
123 });
124 }
125
126 let actions: Vec<String> = self
127 .handlers
128 .iter()
129 .filter(|h| match (&h.event_type, &request.event_type) {
130 (None, _) => true, (Some(expected), Some(actual)) => expected == actual,
132 (Some(_), None) => false, })
134 .map(|h| h.action.clone())
135 .collect();
136
137 Ok(WebhookResult { actions, verified })
138 }
139}
140
141pub fn compute_hmac_signature(secret: &str, body: &[u8]) -> String {
143 let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).expect("HMAC key length");
144 mac.update(body);
145 let result = mac.finalize();
146 hex::encode(&result.into_bytes())
147}
148
149mod hex {
151 pub fn encode(bytes: &[u8]) -> String {
152 bytes.iter().map(|b| format!("{:02x}", b)).collect()
153 }
154
155 pub fn decode(s: &str) -> Result<Vec<u8>, String> {
156 if !s.len().is_multiple_of(2) {
157 return Err("Odd-length hex string".to_string());
158 }
159 (0..s.len())
160 .step_by(2)
161 .map(|i| {
162 u8::from_str_radix(&s[i..i + 2], 16)
163 .map_err(|e| format!("Invalid hex at position {}: {}", i, e))
164 })
165 .collect()
166 }
167}
168
169#[cfg(test)]
170mod tests {
171 use super::*;
172
173 fn make_endpoint_with_secret() -> WebhookEndpoint {
174 WebhookEndpoint::new("/webhooks/test")
175 .with_secret("my-secret-key")
176 .with_handler(WebhookHandler {
177 event_type: Some("push".to_string()),
178 action: "run tests".to_string(),
179 })
180 .with_handler(WebhookHandler {
181 event_type: Some("pull_request".to_string()),
182 action: "run review".to_string(),
183 })
184 .with_handler(WebhookHandler {
185 event_type: None,
186 action: "log event".to_string(),
187 })
188 }
189
190 #[test]
191 fn test_webhook_hmac_verification_valid() {
192 let endpoint = make_endpoint_with_secret();
193 let body = b"test body content";
194 let sig = compute_hmac_signature("my-secret-key", body);
195 let result = endpoint
196 .verify_signature(body, Some(&format!("sha256={}", sig)))
197 .unwrap();
198 assert!(result);
199 }
200
201 #[test]
202 fn test_webhook_hmac_verification_invalid() {
203 let endpoint = make_endpoint_with_secret();
204 let body = b"test body content";
205 let result = endpoint
206 .verify_signature(
207 body,
208 Some("sha256=0000000000000000000000000000000000000000000000000000000000000000"),
209 )
210 .unwrap();
211 assert!(!result);
212 }
213
214 #[test]
215 fn test_webhook_hmac_no_secret_passes() {
216 let endpoint = WebhookEndpoint::new("/webhooks/open");
217 let result = endpoint.verify_signature(b"anything", None).unwrap();
218 assert!(result);
219 }
220
221 #[test]
222 fn test_webhook_hmac_missing_signature_errors() {
223 let endpoint = make_endpoint_with_secret();
224 let result = endpoint.verify_signature(b"body", None);
225 assert!(result.is_err());
226 }
227
228 #[test]
229 fn test_webhook_event_type_filter() {
230 let endpoint = make_endpoint_with_secret();
231 let body = b"push event";
232 let sig = compute_hmac_signature("my-secret-key", body);
233
234 let request = WebhookRequest {
235 path: "/webhooks/test".to_string(),
236 body: body.to_vec(),
237 signature: Some(format!("sha256={}", sig)),
238 event_type: Some("push".to_string()),
239 };
240
241 let result = endpoint.process(&request).unwrap();
242 assert!(result.verified);
243 assert_eq!(result.actions.len(), 2);
245 assert!(result.actions.contains(&"run tests".to_string()));
246 assert!(result.actions.contains(&"log event".to_string()));
247 }
248
249 #[test]
250 fn test_webhook_handler_extracts_action() {
251 let endpoint = make_endpoint_with_secret();
252 let body = b"pr event";
253 let sig = compute_hmac_signature("my-secret-key", body);
254
255 let request = WebhookRequest {
256 path: "/webhooks/test".to_string(),
257 body: body.to_vec(),
258 signature: Some(format!("sha256={}", sig)),
259 event_type: Some("pull_request".to_string()),
260 };
261
262 let result = endpoint.process(&request).unwrap();
263 assert!(result.actions.contains(&"run review".to_string()));
264 assert!(result.actions.contains(&"log event".to_string()));
265 assert!(!result.actions.contains(&"run tests".to_string()));
266 }
267
268 #[test]
269 fn test_webhook_endpoint_config_serde() {
270 let endpoint = make_endpoint_with_secret();
271 let json = serde_json::to_string(&endpoint).unwrap();
272 let deserialized: WebhookEndpoint = serde_json::from_str(&json).unwrap();
273 assert_eq!(deserialized.path, "/webhooks/test");
274 assert_eq!(deserialized.secret, Some("my-secret-key".to_string()));
275 assert_eq!(deserialized.handlers.len(), 3);
276 }
277
278 #[test]
279 fn test_webhook_no_matching_event() {
280 let endpoint = WebhookEndpoint::new("/hooks").with_handler(WebhookHandler {
281 event_type: Some("push".to_string()),
282 action: "deploy".to_string(),
283 });
284
285 let request = WebhookRequest {
286 path: "/hooks".to_string(),
287 body: b"data".to_vec(),
288 signature: None,
289 event_type: Some("issue".to_string()),
290 };
291
292 let result = endpoint.process(&request).unwrap();
293 assert!(result.actions.is_empty());
294 }
295
296 #[test]
297 fn test_hex_roundtrip() {
298 let original = b"hello world";
299 let encoded = hex::encode(original);
300 let decoded = hex::decode(&encoded).unwrap();
301 assert_eq!(decoded, original);
302 }
303}