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    /// Provider-agnostic build-completion trigger (`/webhooks/ci`). Fired by an
16    /// upstream CI build finishing rather than by a git push, so it carries no
17    /// native provider payload — any CI system (incl. very old pipelines) can
18    /// post a small signed JSON body.
19    Ci,
20}
21
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct WebhookEvent {
24    pub provider: WebhookProvider,
25    pub repo_url: String,
26    pub branch: String,
27    pub commit_sha: String,
28    pub pusher: Option<String>,
29}
30
31// ── HMAC-SHA256 verification ──────────────────────────────────────────────────
32
33/// Verify a GitHub-style `sha256=<hex>` HMAC-SHA256 signature.
34/// Returns `false` for any malformed input rather than erroring.
35#[must_use]
36pub fn verify_github_sig(body: &[u8], sig_header: &str, secret: &str) -> bool {
37    use ring::hmac;
38
39    let Some(hex_sig) = sig_header.strip_prefix("sha256=") else {
40        return false;
41    };
42    let key = hmac::Key::new(hmac::HMAC_SHA256, secret.as_bytes());
43    let computed = hmac::sign(&key, body);
44    let expected_hex = bytes_to_hex(computed.as_ref());
45    constant_eq_str(&expected_hex, hex_sig)
46}
47
48/// Bitbucket uses the same HMAC-SHA256 scheme as GitHub.
49#[must_use]
50pub fn verify_bitbucket_sig(body: &[u8], sig_header: &str, secret: &str) -> bool {
51    verify_github_sig(body, sig_header, secret)
52}
53
54/// Compute the HMAC-SHA256 of `msg` keyed by `secret`, returned as a lowercase
55/// hex string. Shared helper so other crates can build keyed integrity chains
56/// without taking their own crypto dependency.
57#[must_use]
58pub fn hmac_sha256_hex(secret: &[u8], msg: &[u8]) -> String {
59    use ring::hmac;
60    let key = hmac::Key::new(hmac::HMAC_SHA256, secret);
61    let tag = hmac::sign(&key, msg);
62    bytes_to_hex(tag.as_ref())
63}
64
65fn bytes_to_hex(bytes: &[u8]) -> String {
66    use std::fmt::Write as _;
67    bytes
68        .iter()
69        .fold(String::with_capacity(bytes.len() * 2), |mut s, b| {
70            write!(s, "{b:02x}").expect("write to String is infallible");
71            s
72        })
73}
74
75fn constant_eq_str(a: &str, b: &str) -> bool {
76    use subtle::ConstantTimeEq;
77    a.as_bytes().ct_eq(b.as_bytes()).into()
78}
79
80// ── payload parsers ───────────────────────────────────────────────────────────
81
82/// Parse a GitHub `push` webhook payload.
83///
84/// # Errors
85/// Returns an error if the body is not valid JSON or required fields are missing.
86pub fn parse_github_push(body: &[u8]) -> Result<WebhookEvent> {
87    let v: serde_json::Value = serde_json::from_slice(body)?;
88    let repo_url = require_str(&v, &["repository", "clone_url"], "repository.clone_url")?;
89    let ref_str = v["ref"]
90        .as_str()
91        .ok_or_else(|| anyhow::anyhow!("missing field: ref"))?;
92    let branch = strip_refs_heads(ref_str);
93    let commit_sha = v["after"]
94        .as_str()
95        .filter(|s| !s.is_empty())
96        .ok_or_else(|| anyhow::anyhow!("missing field: after"))?
97        .to_owned();
98    let pusher = v["pusher"]["name"].as_str().map(str::to_owned);
99    Ok(WebhookEvent {
100        provider: WebhookProvider::GitHub,
101        repo_url,
102        branch,
103        commit_sha,
104        pusher,
105    })
106}
107
108/// Parse a GitLab `push` webhook payload.
109///
110/// # Errors
111/// Returns an error if the body is not valid JSON or required fields are missing.
112pub fn parse_gitlab_push(body: &[u8]) -> Result<WebhookEvent> {
113    let v: serde_json::Value = serde_json::from_slice(body)?;
114    let repo_url = require_str(&v, &["project", "git_http_url"], "project.git_http_url")?;
115    let ref_str = v["ref"]
116        .as_str()
117        .ok_or_else(|| anyhow::anyhow!("missing field: ref"))?;
118    let branch = strip_refs_heads(ref_str);
119    let commit_sha = v["checkout_sha"]
120        .as_str()
121        .filter(|s| !s.is_empty())
122        .ok_or_else(|| anyhow::anyhow!("missing field: checkout_sha"))?
123        .to_owned();
124    let pusher = v["user_username"].as_str().map(str::to_owned);
125    Ok(WebhookEvent {
126        provider: WebhookProvider::GitLab,
127        repo_url,
128        branch,
129        commit_sha,
130        pusher,
131    })
132}
133
134/// Parse a Bitbucket Server / Cloud `push` webhook payload.
135///
136/// # Errors
137/// Returns an error if the body is not valid JSON or required fields are missing.
138pub fn parse_bitbucket_push(body: &[u8]) -> Result<WebhookEvent> {
139    let v: serde_json::Value = serde_json::from_slice(body)?;
140    let repo_url = extract_bitbucket_clone_url(&v)
141        .ok_or_else(|| anyhow::anyhow!("missing field: repository.links.clone[https].href"))?;
142    let push = &v["push"]["changes"][0]["new"];
143    let branch = push["name"]
144        .as_str()
145        .filter(|s| !s.is_empty())
146        .ok_or_else(|| anyhow::anyhow!("missing field: push.changes[0].new.name"))?
147        .to_owned();
148    let commit_sha = push["target"]["hash"]
149        .as_str()
150        .filter(|s| !s.is_empty())
151        .ok_or_else(|| anyhow::anyhow!("missing field: push.changes[0].new.target.hash"))?
152        .to_owned();
153    let pusher = v["actor"]["display_name"].as_str().map(str::to_owned);
154    Ok(WebhookEvent {
155        provider: WebhookProvider::Bitbucket,
156        repo_url,
157        branch,
158        commit_sha,
159        pusher,
160    })
161}
162
163// ── helpers ───────────────────────────────────────────────────────────────────
164
165fn require_str(v: &serde_json::Value, path: &[&str], field: &str) -> Result<String> {
166    let s = path
167        .iter()
168        .fold(v, |cur, key| &cur[key])
169        .as_str()
170        .filter(|s| !s.is_empty())
171        .ok_or_else(|| anyhow::anyhow!("missing field: {field}"))?;
172    Ok(s.to_owned())
173}
174
175fn strip_refs_heads(r: &str) -> String {
176    r.strip_prefix("refs/heads/").unwrap_or(r).to_owned()
177}
178
179fn extract_bitbucket_clone_url(v: &serde_json::Value) -> Option<String> {
180    v["repository"]["links"]["clone"]
181        .as_array()
182        .and_then(|arr| arr.iter().find(|e| e["name"] == "https"))
183        .and_then(|e| e["href"].as_str())
184        .filter(|s| !s.is_empty())
185        .map(str::to_owned)
186}