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
26pub 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
42pub 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
61pub fn parse_github_push(body: &[u8]) -> Result<WebhookEvent> {
65 let v: serde_json::Value = serde_json::from_slice(body)?;
66 let repo_url = string_at(&v, &["repository", "clone_url"]);
67 let ref_str = v["ref"].as_str().unwrap_or("");
68 let branch = strip_refs_heads(ref_str);
69 let commit_sha = v["after"].as_str().unwrap_or("").to_owned();
70 let pusher = v["pusher"]["name"].as_str().map(str::to_owned);
71 Ok(WebhookEvent {
72 provider: WebhookProvider::GitHub,
73 repo_url,
74 branch,
75 commit_sha,
76 pusher,
77 })
78}
79
80pub fn parse_gitlab_push(body: &[u8]) -> Result<WebhookEvent> {
82 let v: serde_json::Value = serde_json::from_slice(body)?;
83 let repo_url = string_at(&v, &["project", "git_http_url"]);
84 let ref_str = v["ref"].as_str().unwrap_or("");
85 let branch = strip_refs_heads(ref_str);
86 let commit_sha = v["checkout_sha"].as_str().unwrap_or("").to_owned();
87 let pusher = v["user_username"].as_str().map(str::to_owned);
88 Ok(WebhookEvent {
89 provider: WebhookProvider::GitLab,
90 repo_url,
91 branch,
92 commit_sha,
93 pusher,
94 })
95}
96
97pub fn parse_bitbucket_push(body: &[u8]) -> Result<WebhookEvent> {
99 let v: serde_json::Value = serde_json::from_slice(body)?;
100 let repo_url = extract_bitbucket_clone_url(&v);
101 let push = &v["push"]["changes"][0]["new"];
102 let branch = push["name"].as_str().unwrap_or("").to_owned();
103 let commit_sha = push["target"]["hash"].as_str().unwrap_or("").to_owned();
104 let pusher = v["actor"]["display_name"].as_str().map(str::to_owned);
105 Ok(WebhookEvent {
106 provider: WebhookProvider::Bitbucket,
107 repo_url,
108 branch,
109 commit_sha,
110 pusher,
111 })
112}
113
114fn string_at(v: &serde_json::Value, path: &[&str]) -> String {
117 path.iter()
118 .fold(v, |cur, key| &cur[key])
119 .as_str()
120 .unwrap_or("")
121 .to_owned()
122}
123
124fn strip_refs_heads(r: &str) -> String {
125 r.strip_prefix("refs/heads/").unwrap_or(r).to_owned()
126}
127
128fn extract_bitbucket_clone_url(v: &serde_json::Value) -> String {
129 v["repository"]["links"]["clone"]
130 .as_array()
131 .and_then(|arr| arr.iter().find(|e| e["name"] == "https"))
132 .and_then(|e| e["href"].as_str())
133 .unwrap_or("")
134 .to_owned()
135}