1use serde_json::Value;
15
16pub type Headers<'a> = dyn Fn(&str) -> Option<String> + 'a;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum Provider {
22 GitHub,
23 Gitea,
24 GitLab,
25 Generic,
26}
27
28#[derive(Debug, Clone, PartialEq, Eq)]
30pub enum Event {
31 Ping,
33 Push {
35 reference: String,
36 after: Option<String>,
37 message: Option<String>,
38 deleted: bool,
39 },
40 Trigger,
42 PullRequest(PullRequest),
44 Other(String),
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum PrAction {
51 Open,
53 Sync,
55 Close { merged: bool },
57 Other,
59}
60
61#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct PullRequest {
65 pub action: PrAction,
66 pub raw_action: String,
68 pub number: u64,
70 pub head_sha: Option<String>,
71 pub head_ref: String,
73 pub base_ref: String,
75 pub fork: bool,
78 pub title: String,
79}
80
81impl PullRequest {
82 pub fn head_ref_in_base(provider: Provider, number: u64) -> String {
85 match provider {
86 Provider::GitLab => format!("refs/merge-requests/{number}/head"),
87 _ => format!("refs/pull/{number}/head"),
88 }
89 }
90}
91
92#[derive(Debug, Clone, PartialEq, Eq)]
94pub enum Refusal {
95 Missing,
97 Invalid,
99}
100
101pub fn verify(
103 secret: &[u8],
104 header: &Headers,
105 query_token: Option<&str>,
106 body: &[u8],
107) -> Result<Provider, Refusal> {
108 if let Some(sig) = header("x-gitea-signature").or_else(|| header("x-forgejo-signature")) {
111 return hmac_ok(secret, body, sig.trim())
112 .then_some(Provider::Gitea)
113 .ok_or(Refusal::Invalid);
114 }
115 if let Some(sig) = header("x-hub-signature-256") {
116 let hex = sig.trim().strip_prefix("sha256=").ok_or(Refusal::Invalid)?;
117 return hmac_ok(secret, body, hex)
118 .then_some(Provider::GitHub)
119 .ok_or(Refusal::Invalid);
120 }
121 if let Some(tok) = header("x-gitlab-token") {
122 return ct_eq(tok.trim().as_bytes(), secret)
123 .then_some(Provider::GitLab)
124 .ok_or(Refusal::Invalid);
125 }
126 if let Some(tok) = query_token {
127 return ct_eq(tok.as_bytes(), secret)
128 .then_some(Provider::Generic)
129 .ok_or(Refusal::Invalid);
130 }
131 Err(Refusal::Missing)
132}
133
134fn hmac_ok(secret: &[u8], body: &[u8], hex: &str) -> bool {
135 let Some(sig) = from_hex(hex) else {
136 return false;
137 };
138 let key = ring::hmac::Key::new(ring::hmac::HMAC_SHA256, secret);
139 ring::hmac::verify(&key, body, &sig).is_ok()
140}
141
142pub fn sign(secret: &[u8], body: &[u8]) -> String {
145 let key = ring::hmac::Key::new(ring::hmac::HMAC_SHA256, secret);
146 to_hex(ring::hmac::sign(&key, body).as_ref())
147}
148
149fn ct_eq(a: &[u8], b: &[u8]) -> bool {
150 let da = ring::digest::digest(&ring::digest::SHA256, a);
152 let db = ring::digest::digest(&ring::digest::SHA256, b);
153 let mut d = 0u8;
154 for (x, y) in da.as_ref().iter().zip(db.as_ref()) {
155 d |= x ^ y;
156 }
157 d == 0
158}
159
160fn from_hex(s: &str) -> Option<Vec<u8>> {
161 if s.len() % 2 != 0 {
162 return None;
163 }
164 (0..s.len())
165 .step_by(2)
166 .map(|i| u8::from_str_radix(s.get(i..i + 2)?, 16).ok())
167 .collect()
168}
169
170fn to_hex(b: &[u8]) -> String {
171 b.iter().map(|x| format!("{x:02x}")).collect()
172}
173
174pub fn event(provider: Provider, header: &Headers, body: &[u8]) -> Event {
176 let kind = match provider {
177 Provider::GitHub => header("x-github-event"),
178 Provider::Gitea => header("x-gitea-event").or_else(|| header("x-forgejo-event")),
179 Provider::GitLab => header("x-gitlab-event"),
180 Provider::Generic => header("x-github-event")
181 .or_else(|| header("x-gitea-event"))
182 .or_else(|| header("x-gitlab-event")),
183 };
184 let json: Option<Value> = serde_json::from_slice(body).ok();
185 match kind.as_deref().map(str::to_ascii_lowercase).as_deref() {
186 Some("ping") => return Event::Ping,
187 Some("push" | "push hook" | "tag push hook") => {}
188 Some("pull_request" | "merge request hook") => {
189 return match json.as_ref().and_then(|j| pull_request(provider, j)) {
190 Some(pr) => Event::PullRequest(pr),
191 None => Event::Other("pull request without a number".into()),
192 };
193 }
194 Some(other) => return Event::Other(other.to_string()),
195 None if provider == Provider::Generic => {}
196 None => return Event::Other("unknown".into()),
197 }
198 let Some(j) = json.filter(|j| j.get("ref").is_some()) else {
199 return if provider == Provider::Generic {
200 Event::Trigger
201 } else {
202 Event::Other("push without a ref".into())
203 };
204 };
205 let s = |v: &Value| v.as_str().map(String::from);
206 let after = j
207 .get("after")
208 .and_then(s)
209 .or_else(|| j.get("checkout_sha").and_then(s));
210 let zero = after
211 .as_deref()
212 .is_some_and(|a| a.bytes().all(|b| b == b'0'));
213 let message = j
214 .pointer("/head_commit/message")
215 .and_then(s)
216 .or_else(|| {
217 j.get("commits")
218 .and_then(Value::as_array)
219 .and_then(|c| c.last())
220 .and_then(|c| c.get("message"))
221 .and_then(s)
222 })
223 .map(|m| m.lines().next().unwrap_or("").to_string());
224 Event::Push {
225 reference: j.get("ref").and_then(s).unwrap_or_default(),
226 after: after.filter(|_| !zero),
227 message,
228 deleted: zero || j.get("deleted").and_then(Value::as_bool) == Some(true),
229 }
230}
231
232fn pull_request(provider: Provider, j: &Value) -> Option<PullRequest> {
236 let s = |p: &str| j.pointer(p).and_then(Value::as_str).map(String::from);
237 if provider == Provider::GitLab
238 || j.get("object_kind").and_then(Value::as_str) == Some("merge_request")
239 {
240 let a = j.get("object_attributes")?;
241 let number = a.get("iid").and_then(Value::as_u64)?;
242 let raw = s("/object_attributes/action").unwrap_or_default();
243 let action = match raw.as_str() {
246 "open" | "reopen" => PrAction::Open,
247 "update" if a.get("oldrev").is_some_and(|v| v.is_string()) => PrAction::Sync,
248 "close" => PrAction::Close { merged: false },
249 "merge" => PrAction::Close { merged: true },
250 _ => PrAction::Other,
251 };
252 let src = a.get("source_project_id").and_then(Value::as_u64);
253 let dst = a.get("target_project_id").and_then(Value::as_u64);
254 return Some(PullRequest {
255 action,
256 raw_action: raw,
257 number,
258 head_sha: s("/object_attributes/last_commit/id"),
259 head_ref: s("/object_attributes/source_branch").unwrap_or_default(),
260 base_ref: s("/object_attributes/target_branch").unwrap_or_default(),
261 fork: src.is_none() || src != dst,
263 title: s("/object_attributes/title").unwrap_or_default(),
264 });
265 }
266 let pr = j.get("pull_request")?;
267 let number = j
268 .get("number")
269 .and_then(Value::as_u64)
270 .or_else(|| pr.get("number").and_then(Value::as_u64))?;
271 let raw = s("/action").unwrap_or_default();
272 let merged = pr.get("merged").and_then(Value::as_bool) == Some(true);
273 let action = match raw.as_str() {
274 "opened" | "reopened" => PrAction::Open,
275 "synchronize" | "synchronized" => PrAction::Sync,
277 "closed" => PrAction::Close { merged },
278 _ => PrAction::Other,
279 };
280 let head_repo = s("/pull_request/head/repo/full_name");
283 let base_repo = s("/pull_request/base/repo/full_name").or_else(|| s("/repository/full_name"));
284 let fork = match (&head_repo, &base_repo) {
285 (Some(h), Some(b)) => !h.eq_ignore_ascii_case(b),
286 _ => true,
287 };
288 Some(PullRequest {
289 action,
290 raw_action: raw,
291 number,
292 head_sha: s("/pull_request/head/sha"),
293 head_ref: s("/pull_request/head/ref").unwrap_or_default(),
294 base_ref: s("/pull_request/base/ref").unwrap_or_default(),
295 fork,
296 title: s("/pull_request/title").unwrap_or_default(),
297 })
298}
299
300pub fn delivery_id(header: &Headers) -> Option<String> {
302 header("x-github-delivery")
303 .or_else(|| header("x-gitea-delivery"))
304 .or_else(|| header("x-forgejo-delivery"))
305 .or_else(|| header("x-gitlab-event-uuid"))
306 .filter(|d| !d.is_empty() && d.len() <= 200)
307}
308
309#[cfg(test)]
310mod tests {
311 use super::*;
312
313 fn headers(h: &[(&str, &str)]) -> Box<Headers<'static>> {
314 let h: Vec<(String, String)> = h
315 .iter()
316 .map(|(k, v)| (k.to_ascii_lowercase(), v.to_string()))
317 .collect();
318 Box::new(move |k: &str| {
319 h.iter()
320 .find(|(n, _)| n == &k.to_ascii_lowercase())
321 .map(|(_, v)| v.clone())
322 })
323 }
324
325 const SECRET: &[u8] = b"It's a Secret to Everybody";
326
327 #[test]
328 fn github_signature() {
329 let body = b"Hello, World!";
331 let want = "757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17";
332 assert_eq!(sign(SECRET, body), want);
333 let ok = headers(&[("X-Hub-Signature-256", &format!("sha256={want}"))]);
334 assert_eq!(verify(SECRET, &ok, None, body), Ok(Provider::GitHub));
335 assert_eq!(
337 verify(SECRET, &ok, None, b"Hello, World?"),
338 Err(Refusal::Invalid)
339 );
340 assert_eq!(verify(b"other", &ok, None, body), Err(Refusal::Invalid));
341 let bare = headers(&[("X-Hub-Signature-256", want)]);
342 assert_eq!(verify(SECRET, &bare, None, body), Err(Refusal::Invalid));
343 let odd = headers(&[("X-Hub-Signature-256", "sha256=zz")]);
344 assert_eq!(verify(SECRET, &odd, None, body), Err(Refusal::Invalid));
345 let wrong = headers(&[("X-Hub-Signature-256", "sha256=00")]);
348 assert_eq!(
349 verify(SECRET, &wrong, Some("It's a Secret to Everybody"), body),
350 Err(Refusal::Invalid)
351 );
352 }
353
354 #[test]
355 fn gitea_gitlab_and_token() {
356 let body = br#"{"ref":"refs/heads/main"}"#;
357 let sig = sign(SECRET, body);
358 let gitea = headers(&[("X-Gitea-Signature", &sig)]);
359 assert_eq!(verify(SECRET, &gitea, None, body), Ok(Provider::Gitea));
360 let forgejo = headers(&[("X-Forgejo-Signature", &sig)]);
361 assert_eq!(verify(SECRET, &forgejo, None, body), Ok(Provider::Gitea));
362 let both = headers(&[
364 ("X-Gitea-Signature", &sig),
365 ("X-Hub-Signature-256", &format!("sha256={sig}")),
366 ]);
367 assert_eq!(verify(SECRET, &both, None, body), Ok(Provider::Gitea));
368 let gl = headers(&[("X-Gitlab-Token", "It's a Secret to Everybody")]);
369 assert_eq!(verify(SECRET, &gl, None, body), Ok(Provider::GitLab));
370 let gl_bad = headers(&[("X-Gitlab-Token", "It's a Secret to Everybod")]);
371 assert_eq!(verify(SECRET, &gl_bad, None, body), Err(Refusal::Invalid));
372 let none = headers(&[]);
373 assert_eq!(
374 verify(SECRET, &none, Some("It's a Secret to Everybody"), body),
375 Ok(Provider::Generic)
376 );
377 assert_eq!(
378 verify(SECRET, &none, Some("nope"), body),
379 Err(Refusal::Invalid)
380 );
381 assert_eq!(verify(SECRET, &none, None, body), Err(Refusal::Missing));
382 }
383
384 #[test]
385 fn events() {
386 let push = br#"{"ref":"refs/heads/main","after":"abc123","head_commit":{"message":"fix: it\n\nbody"}}"#;
387 let gh = headers(&[("X-GitHub-Event", "push")]);
388 assert_eq!(
389 event(Provider::GitHub, &gh, push),
390 Event::Push {
391 reference: "refs/heads/main".into(),
392 after: Some("abc123".into()),
393 message: Some("fix: it".into()),
394 deleted: false,
395 }
396 );
397 let ping = headers(&[("X-GitHub-Event", "ping")]);
398 assert_eq!(event(Provider::GitHub, &ping, b"{}"), Event::Ping);
399 let issues = headers(&[("X-GitHub-Event", "issues")]);
402 assert_eq!(
403 event(Provider::GitHub, &issues, push),
404 Event::Other("issues".into())
405 );
406 let gl = headers(&[("X-Gitlab-Event", "Push Hook")]);
407 let gl_body = br#"{"ref":"refs/heads/dev","checkout_sha":"def","commits":[{"message":"one"},{"message":"two"}]}"#;
408 assert_eq!(
409 event(Provider::GitLab, &gl, gl_body),
410 Event::Push {
411 reference: "refs/heads/dev".into(),
412 after: Some("def".into()),
413 message: Some("two".into()),
414 deleted: false,
415 }
416 );
417 let del = br#"{"ref":"refs/heads/main","after":"0000000000000000000000000000000000000000","deleted":true}"#;
418 assert!(matches!(
419 event(Provider::Gitea, &headers(&[("X-Gitea-Event", "push")]), del),
420 Event::Push {
421 deleted: true,
422 after: None,
423 ..
424 }
425 ));
426 assert_eq!(event(Provider::Generic, &headers(&[]), b""), Event::Trigger);
427 let pr = br#"{"action":"opened","number":3,"pull_request":{"head":{"sha":"abc","ref":"f","repo":{"full_name":"o/r"}},"base":{"ref":"main","repo":{"full_name":"o/r"}}}}"#;
429 assert!(matches!(
430 event(
431 Provider::GitHub,
432 &headers(&[("X-GitHub-Event", "pull_request")]),
433 pr
434 ),
435 Event::PullRequest(_)
436 ));
437 assert!(matches!(
438 event(Provider::Generic, &headers(&[]), push),
439 Event::Push { .. }
440 ));
441 }
442
443 fn pr_of(provider: Provider, h: &[(&str, &str)], body: &str) -> PullRequest {
444 match event(provider, &headers(h), body.as_bytes()) {
445 Event::PullRequest(p) => p,
446 e => panic!("not a pull request: {e:?}"),
447 }
448 }
449
450 fn github(action: &str, head_repo: &str, merged: bool) -> String {
452 format!(
453 r#"{{"action":"{action}","number":42,"pull_request":{{"number":42,"title":"Add a thing","merged":{merged},
454 "head":{{"ref":"feature","sha":"0123456789abcdef0123456789abcdef01234567","repo":{{"full_name":"{head_repo}","fork":{}}}}},
455 "base":{{"ref":"main","sha":"fedcba","repo":{{"full_name":"acme/web"}}}}}},
456 "repository":{{"full_name":"acme/web"}}}}"#,
457 head_repo != "acme/web"
458 )
459 }
460
461 #[test]
462 fn github_pull_requests() {
463 let h = [("X-GitHub-Event", "pull_request")];
464 let p = pr_of(Provider::GitHub, &h, &github("opened", "acme/web", false));
465 assert_eq!(p.action, PrAction::Open);
466 assert_eq!(p.number, 42);
467 assert_eq!(p.head_ref, "feature");
468 assert_eq!(p.base_ref, "main");
469 assert_eq!(p.title, "Add a thing");
470 assert_eq!(
471 p.head_sha.as_deref(),
472 Some("0123456789abcdef0123456789abcdef01234567")
473 );
474 assert!(!p.fork);
475 let p = pr_of(Provider::GitHub, &h, &github("reopened", "acme/web", false));
476 assert_eq!(p.action, PrAction::Open);
477 let p = pr_of(
478 Provider::GitHub,
479 &h,
480 &github("synchronize", "acme/web", false),
481 );
482 assert_eq!(p.action, PrAction::Sync);
483 let p = pr_of(Provider::GitHub, &h, &github("closed", "acme/web", false));
484 assert_eq!(p.action, PrAction::Close { merged: false });
485 let p = pr_of(Provider::GitHub, &h, &github("closed", "acme/web", true));
486 assert_eq!(p.action, PrAction::Close { merged: true });
487 let p = pr_of(Provider::GitHub, &h, &github("labeled", "acme/web", false));
488 assert_eq!(p.action, PrAction::Other);
489 let p = pr_of(
491 Provider::GitHub,
492 &h,
493 &github("opened", "mallory/web", false),
494 );
495 assert!(p.fork);
496 let gone = r#"{"action":"synchronize","number":5,"pull_request":{"head":{"ref":"x","sha":"ab","repo":null},"base":{"ref":"main","repo":{"full_name":"acme/web"}}}}"#;
497 assert!(pr_of(Provider::GitHub, &h, gone).fork);
498 let p = pr_of(Provider::GitHub, &h, &github("opened", "ACME/Web", false));
500 assert!(!p.fork);
501 assert!(matches!(
503 event(Provider::GitHub, &headers(&h), br#"{"action":"opened"}"#),
504 Event::Other(_)
505 ));
506 }
507
508 #[test]
509 fn gitea_pull_requests() {
510 let body = |action: &str, head: &str, merged: bool| {
513 format!(
514 r#"{{"action":"{action}","number":7,"pull_request":{{"id":99,"number":7,"title":"t","merged":{merged},
515 "head":{{"label":"f","ref":"f","sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","repo_id":2,"repo":{{"id":2,"full_name":"{head}"}}}},
516 "base":{{"label":"main","ref":"main","sha":"b","repo_id":1,"repo":{{"id":1,"full_name":"isb/app"}}}}}},
517 "repository":{{"id":1,"full_name":"isb/app"}}}}"#
518 )
519 };
520 let h = [
521 ("X-Gitea-Event", "pull_request"),
522 ("X-Gitea-Event-Type", "pull_request"),
523 ];
524 let p = pr_of(Provider::Gitea, &h, &body("opened", "isb/app", false));
525 assert_eq!((p.action, p.number, p.fork), (PrAction::Open, 7, false));
526 let hs = [
527 ("X-Gitea-Event", "pull_request"),
528 ("X-Gitea-Event-Type", "pull_request_sync"),
529 ];
530 let p = pr_of(
531 Provider::Gitea,
532 &hs,
533 &body("synchronized", "isb/app", false),
534 );
535 assert_eq!(p.action, PrAction::Sync);
536 let p = pr_of(Provider::Gitea, &h, &body("closed", "isb/app", true));
537 assert_eq!(p.action, PrAction::Close { merged: true });
538 let p = pr_of(Provider::Gitea, &h, &body("opened", "someone/app", false));
539 assert!(p.fork);
540 let f = [("X-Forgejo-Event", "pull_request")];
541 assert_eq!(
542 pr_of(Provider::Gitea, &f, &body("reopened", "isb/app", false)).action,
543 PrAction::Open
544 );
545 let rv = [("X-Gitea-Event", "pull_request_approved")];
547 assert!(matches!(
548 event(
549 Provider::Gitea,
550 &headers(&rv),
551 body("reviewed", "isb/app", false).as_bytes()
552 ),
553 Event::Other(_)
554 ));
555 }
556
557 #[test]
558 fn gitlab_merge_requests() {
559 let body = |action: &str, oldrev: bool, src: u64| {
560 format!(
561 r#"{{"object_kind":"merge_request","project":{{"id":1,"path_with_namespace":"acme/web"}},
562 "object_attributes":{{"iid":12,"title":"MR","action":"{action}","state":"opened",
563 "source_branch":"feat","target_branch":"main","source_project_id":{src},"target_project_id":1,
564 {}"last_commit":{{"id":"cccccccccccccccccccccccccccccccccccccccc","message":"m"}}}}}}"#,
565 if oldrev {
566 r#""oldrev":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","#
567 } else {
568 ""
569 }
570 )
571 };
572 let h = [("X-Gitlab-Event", "Merge Request Hook")];
573 let p = pr_of(Provider::GitLab, &h, &body("open", false, 1));
574 assert_eq!((p.action, p.number, p.fork), (PrAction::Open, 12, false));
575 assert_eq!(p.head_ref, "feat");
576 assert_eq!(p.base_ref, "main");
577 assert_eq!(
578 p.head_sha.as_deref(),
579 Some("cccccccccccccccccccccccccccccccccccccccc")
580 );
581 assert_eq!(
582 pr_of(Provider::GitLab, &h, &body("reopen", false, 1)).action,
583 PrAction::Open
584 );
585 assert_eq!(
588 pr_of(Provider::GitLab, &h, &body("update", true, 1)).action,
589 PrAction::Sync
590 );
591 assert_eq!(
592 pr_of(Provider::GitLab, &h, &body("update", false, 1)).action,
593 PrAction::Other
594 );
595 assert_eq!(
596 pr_of(Provider::GitLab, &h, &body("close", false, 1)).action,
597 PrAction::Close { merged: false }
598 );
599 assert_eq!(
600 pr_of(Provider::GitLab, &h, &body("merge", false, 1)).action,
601 PrAction::Close { merged: true }
602 );
603 assert!(pr_of(Provider::GitLab, &h, &body("open", false, 2)).fork);
604 }
605}