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