Skip to main content

rustant_core/scheduler/
webhook.rs

1//! Webhook endpoint — receives HTTP callbacks, verifies HMAC signatures,
2//! and dispatches handlers.
3
4use hmac::{Hmac, Mac};
5use serde::{Deserialize, Serialize};
6use sha2::Sha256;
7
8use crate::error::SchedulerError;
9
10type HmacSha256 = Hmac<Sha256>;
11
12/// Configuration for a webhook endpoint.
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct WebhookEndpoint {
15    /// URL path to listen on (e.g., "/webhooks/github").
16    pub path: String,
17    /// Optional HMAC-SHA256 secret for signature verification.
18    #[serde(default, skip_serializing_if = "Option::is_none")]
19    pub secret: Option<String>,
20    /// Handlers that can process incoming webhook events.
21    pub handlers: Vec<WebhookHandler>,
22}
23
24/// A handler that maps event types to actions.
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct WebhookHandler {
27    /// The event type to match (e.g., "push", "pull_request").
28    /// If None, matches all events.
29    #[serde(default, skip_serializing_if = "Option::is_none")]
30    pub event_type: Option<String>,
31    /// The action to execute when matched.
32    pub action: String,
33}
34
35/// An incoming webhook request.
36#[derive(Debug, Clone)]
37pub struct WebhookRequest {
38    /// The HTTP path.
39    pub path: String,
40    /// The raw body bytes.
41    pub body: Vec<u8>,
42    /// The signature header value (e.g., "sha256=abc123...").
43    pub signature: Option<String>,
44    /// The event type header value.
45    pub event_type: Option<String>,
46}
47
48/// Result of processing a webhook.
49#[derive(Debug, Clone)]
50pub struct WebhookResult {
51    /// Actions to execute.
52    pub actions: Vec<String>,
53    /// Whether the request was verified.
54    pub verified: bool,
55}
56
57impl WebhookEndpoint {
58    /// Create a new webhook endpoint.
59    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    /// Set the HMAC secret.
68    pub fn with_secret(mut self, secret: impl Into<String>) -> Self {
69        self.secret = Some(secret.into());
70        self
71    }
72
73    /// Add a handler.
74    pub fn with_handler(mut self, handler: WebhookHandler) -> Self {
75        self.handlers.push(handler);
76        self
77    }
78
79    /// Verify the HMAC-SHA256 signature of a request.
80    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), // No secret configured, skip verification
87            Some(secret) => {
88                let sig = signature.ok_or_else(|| SchedulerError::WebhookVerificationFailed {
89                    message: "Missing signature header".to_string(),
90                })?;
91
92                // Parse "sha256=<hex>" format
93                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    /// Process a webhook request: verify + match handlers.
117    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, // Handler matches all events
131                (Some(expected), Some(actual)) => expected == actual,
132                (Some(_), None) => false, // Handler expects event type but none given
133            })
134            .map(|h| h.action.clone())
135            .collect();
136
137        Ok(WebhookResult { actions, verified })
138    }
139}
140
141/// Compute HMAC-SHA256 signature for a body, returning hex-encoded string.
142pub 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
149/// Simple hex encoding (no external crate needed beyond what we have).
150mod 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        // Should match: "push" handler + catch-all handler
244        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}