1use anyhow::Result;
5use serde::{Deserialize, Serialize};
6
7#[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#[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#[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#[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
75pub 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
103pub 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
129pub 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
158fn 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}