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 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#[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#[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#[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
80pub 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
108pub 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
134pub 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
163fn 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}