Skip to main content

sloc_git/
webhook.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2// Copyright (C) 2026 Nima Shafie <nimzshafie@gmail.com>
3
4use anyhow::Result;
5use serde::{Deserialize, Serialize};
6
7// ── types ─────────────────────────────────────────────────────────────────────
8
9#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
10#[serde(rename_all = "snake_case")]
11pub enum WebhookProvider {
12    GitHub,
13    GitLab,
14    Bitbucket,
15}
16
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct WebhookEvent {
19    pub provider: WebhookProvider,
20    pub repo_url: String,
21    pub branch: String,
22    pub commit_sha: String,
23    pub pusher: Option<String>,
24}
25
26// ── HMAC-SHA256 verification ──────────────────────────────────────────────────
27
28/// Verify a GitHub-style `sha256=<hex>` HMAC-SHA256 signature.
29/// Returns `false` for any malformed input rather than erroring.
30pub fn verify_github_sig(body: &[u8], sig_header: &str, secret: &str) -> bool {
31    use ring::hmac;
32
33    let Some(hex_sig) = sig_header.strip_prefix("sha256=") else {
34        return false;
35    };
36    let key = hmac::Key::new(hmac::HMAC_SHA256, secret.as_bytes());
37    let computed = hmac::sign(&key, body);
38    let expected_hex = bytes_to_hex(computed.as_ref());
39    constant_eq_str(&expected_hex, hex_sig)
40}
41
42/// Bitbucket uses the same HMAC-SHA256 scheme as GitHub.
43pub fn verify_bitbucket_sig(body: &[u8], sig_header: &str, secret: &str) -> bool {
44    verify_github_sig(body, sig_header, secret)
45}
46
47fn bytes_to_hex(bytes: &[u8]) -> String {
48    bytes.iter().map(|b| format!("{b:02x}")).collect()
49}
50
51fn constant_eq_str(a: &str, b: &str) -> bool {
52    if a.len() != b.len() {
53        return false;
54    }
55    a.bytes()
56        .zip(b.bytes())
57        .fold(0u8, |acc, (x, y)| acc | (x ^ y))
58        == 0
59}
60
61// ── payload parsers ───────────────────────────────────────────────────────────
62
63/// Parse a GitHub `push` webhook payload.
64pub fn parse_github_push(body: &[u8]) -> Result<WebhookEvent> {
65    let v: serde_json::Value = serde_json::from_slice(body)?;
66    let repo_url = require_str(&v, &["repository", "clone_url"], "repository.clone_url")?;
67    let ref_str = v["ref"]
68        .as_str()
69        .ok_or_else(|| anyhow::anyhow!("missing field: ref"))?;
70    let branch = strip_refs_heads(ref_str);
71    let commit_sha = v["after"]
72        .as_str()
73        .filter(|s| !s.is_empty())
74        .ok_or_else(|| anyhow::anyhow!("missing field: after"))?
75        .to_owned();
76    let pusher = v["pusher"]["name"].as_str().map(str::to_owned);
77    Ok(WebhookEvent {
78        provider: WebhookProvider::GitHub,
79        repo_url,
80        branch,
81        commit_sha,
82        pusher,
83    })
84}
85
86/// Parse a GitLab `push` webhook payload.
87pub fn parse_gitlab_push(body: &[u8]) -> Result<WebhookEvent> {
88    let v: serde_json::Value = serde_json::from_slice(body)?;
89    let repo_url = require_str(&v, &["project", "git_http_url"], "project.git_http_url")?;
90    let ref_str = v["ref"]
91        .as_str()
92        .ok_or_else(|| anyhow::anyhow!("missing field: ref"))?;
93    let branch = strip_refs_heads(ref_str);
94    let commit_sha = v["checkout_sha"]
95        .as_str()
96        .filter(|s| !s.is_empty())
97        .ok_or_else(|| anyhow::anyhow!("missing field: checkout_sha"))?
98        .to_owned();
99    let pusher = v["user_username"].as_str().map(str::to_owned);
100    Ok(WebhookEvent {
101        provider: WebhookProvider::GitLab,
102        repo_url,
103        branch,
104        commit_sha,
105        pusher,
106    })
107}
108
109/// Parse a Bitbucket Server / Cloud `push` webhook payload.
110pub fn parse_bitbucket_push(body: &[u8]) -> Result<WebhookEvent> {
111    let v: serde_json::Value = serde_json::from_slice(body)?;
112    let repo_url = extract_bitbucket_clone_url(&v)
113        .ok_or_else(|| anyhow::anyhow!("missing field: repository.links.clone[https].href"))?;
114    let push = &v["push"]["changes"][0]["new"];
115    let branch = push["name"]
116        .as_str()
117        .filter(|s| !s.is_empty())
118        .ok_or_else(|| anyhow::anyhow!("missing field: push.changes[0].new.name"))?
119        .to_owned();
120    let commit_sha = push["target"]["hash"]
121        .as_str()
122        .filter(|s| !s.is_empty())
123        .ok_or_else(|| anyhow::anyhow!("missing field: push.changes[0].new.target.hash"))?
124        .to_owned();
125    let pusher = v["actor"]["display_name"].as_str().map(str::to_owned);
126    Ok(WebhookEvent {
127        provider: WebhookProvider::Bitbucket,
128        repo_url,
129        branch,
130        commit_sha,
131        pusher,
132    })
133}
134
135// ── helpers ───────────────────────────────────────────────────────────────────
136
137fn require_str(v: &serde_json::Value, path: &[&str], field: &str) -> Result<String> {
138    let s = path
139        .iter()
140        .fold(v, |cur, key| &cur[key])
141        .as_str()
142        .filter(|s| !s.is_empty())
143        .ok_or_else(|| anyhow::anyhow!("missing field: {field}"))?;
144    Ok(s.to_owned())
145}
146
147fn strip_refs_heads(r: &str) -> String {
148    r.strip_prefix("refs/heads/").unwrap_or(r).to_owned()
149}
150
151fn extract_bitbucket_clone_url(v: &serde_json::Value) -> Option<String> {
152    v["repository"]["links"]["clone"]
153        .as_array()
154        .and_then(|arr| arr.iter().find(|e| e["name"] == "https"))
155        .and_then(|e| e["href"].as_str())
156        .filter(|s| !s.is_empty())
157        .map(str::to_owned)
158}