1use aws_lc_rs::hmac;
15use http::HeaderMap;
16use serde_json::Value;
17use vgi_forge::{
18 ForgeAccount, ForgeError, ForgeEvent, ForgeEventKind, InstallationChange, MemberChange,
19 Resource, Result, Visibility,
20};
21
22use crate::secret::Secret;
23
24pub fn verify_signature(secret: &Secret, headers: &HeaderMap, body: &[u8]) -> Result<()> {
26 let header = headers
27 .get("x-hub-signature-256")
28 .ok_or_else(|| ForgeError::Webhook("missing X-Hub-Signature-256".into()))?
29 .to_str()
30 .map_err(|_| ForgeError::Webhook("X-Hub-Signature-256 is not ASCII".into()))?;
31 let hex_tag = header
32 .strip_prefix("sha256=")
33 .ok_or_else(|| ForgeError::Webhook("X-Hub-Signature-256 is not `sha256=<hex>`".into()))?;
34 let tag = hex::decode(hex_tag)
35 .map_err(|_| ForgeError::Webhook("X-Hub-Signature-256 is not hex".into()))?;
36 let key = hmac::Key::new(hmac::HMAC_SHA256, secret.expose().as_bytes());
37 hmac::verify(&key, body, &tag)
39 .map_err(|_| ForgeError::Webhook("signature does not match the body".into()))
40}
41
42pub fn sign_body(secret: &Secret, body: &[u8]) -> String {
45 let key = hmac::Key::new(hmac::HMAC_SHA256, secret.expose().as_bytes());
46 format!("sha256={}", hex::encode(hmac::sign(&key, body).as_ref()))
47}
48
49pub fn parse(
51 secret: &Secret,
52 host: &str,
53 headers: &HeaderMap,
54 body: &[u8],
55) -> Result<Option<ForgeEvent>> {
56 verify_signature(secret, headers, body)?;
57
58 let event = header_str(headers, "x-github-event")?
59 .ok_or_else(|| ForgeError::Webhook("missing X-GitHub-Event".into()))?;
60 let delivery = header_str(headers, "x-github-delivery")?.map(str::to_string);
61 let payload: Value = serde_json::from_slice(body)
62 .map_err(|e| ForgeError::Webhook(format!("body is not JSON: {e}")))?;
63 let action = payload.get("action").and_then(Value::as_str).unwrap_or("");
64
65 let kind = match event {
66 "repository" => repository_event(host, action, &payload)?,
67 "member" => {
68 let change = match action {
69 "added" => MemberChange::Added,
70 "removed" => MemberChange::Removed,
71 "edited" => MemberChange::Edited,
72 _ => return Ok(None),
73 };
74 let (repo, forge_id) = repository(host, &payload)?;
75 Some(ForgeEventKind::CollaboratorChanged {
76 repo,
77 forge_id,
78 account: account(&payload["member"])?,
79 change,
80 })
81 }
82 "membership" => {
85 let change = match action {
86 "added" => MemberChange::Added,
87 "removed" => MemberChange::Removed,
88 _ => return Ok(None),
89 };
90 Some(ForgeEventKind::TeamMembershipChanged {
91 namespace: namespace(host, &payload["organization"])?,
92 team: str_at(payload.get("team").unwrap_or(&Value::Null), &["slug"])?.to_string(),
93 account: account(&payload["member"])?,
94 change,
95 })
96 }
97 "organization" => {
98 let change = match action {
99 "member_added" => MemberChange::Added,
100 "member_removed" => MemberChange::Removed,
101 _ => return Ok(None),
102 };
103 Some(ForgeEventKind::OrgMembershipChanged {
104 namespace: namespace(host, &payload["organization"])?,
105 account: account(&payload["membership"]["user"])?,
106 change,
107 })
108 }
109 "repository_ruleset" | "branch_protection_rule" => {
110 let repo = match payload.get("repository") {
111 Some(r) if !r.is_null() => Some(repository(host, &payload)?.0),
112 _ => None,
113 };
114 let namespace = match &repo {
115 Some(r) => r.namespace(),
116 None => namespace(host, &payload["organization"])?,
117 };
118 Some(ForgeEventKind::ProtectionChanged {
119 repo,
120 namespace,
121 action: action.to_string(),
122 })
123 }
124 "pull_request" => match action {
130 "opened" | "reopened" => {
131 Some(pull_request_opened(host, action == "reopened", &payload)?)
132 }
133 _ => return Ok(None),
134 },
135 "installation" => {
136 let change = match action {
137 "created" => InstallationChange::Created,
138 "deleted" => InstallationChange::Deleted,
139 "suspend" => InstallationChange::Suspended,
140 "unsuspend" => InstallationChange::Unsuspended,
141 "new_permissions_accepted" => InstallationChange::PermissionsAccepted,
142 _ => return Ok(None),
143 };
144 let installation = &payload["installation"];
145 Some(ForgeEventKind::InstallationChanged {
146 namespace: namespace(host, &installation["account"])?,
147 installation_id: u64_field(installation, "id")?,
148 change,
149 })
150 }
151 _ => None,
152 };
153 Ok(kind.map(|k| ForgeEvent::new(delivery, k)))
154}
155
156fn repository_event(host: &str, action: &str, payload: &Value) -> Result<Option<ForgeEventKind>> {
157 let (repo, forge_id) = repository(host, payload)?;
158 Ok(Some(match action {
159 "created" => ForgeEventKind::RepoCreated { repo, forge_id },
160 "deleted" => ForgeEventKind::RepoDeleted { repo, forge_id },
161 "archived" | "unarchived" => ForgeEventKind::RepoArchived {
162 repo,
163 forge_id,
164 archived: action == "archived",
165 },
166 "publicized" | "privatized" => ForgeEventKind::RepoVisibilityChanged {
167 repo,
168 forge_id,
169 visibility: if action == "publicized" {
170 Visibility::Public
171 } else {
172 Visibility::Private
173 },
174 },
175 "renamed" => {
176 let old = str_at(payload, &["changes", "repository", "name", "from"])?;
177 let from = repo.namespace().join(old)?;
178 ForgeEventKind::RepoRenamed {
179 forge_id,
180 from,
181 to: repo,
182 }
183 }
184 "transferred" => {
185 let owner = &payload["changes"]["owner"]["from"];
186 let from_account = if owner["organization"].is_object() {
187 &owner["organization"]
188 } else {
189 &owner["user"]
190 };
191 ForgeEventKind::RepoTransferred {
192 forge_id,
193 from_namespace: namespace(host, from_account).ok(),
194 to: repo,
195 }
196 }
197 _ => return Ok(None),
198 }))
199}
200
201fn pull_request_opened(host: &str, reopened: bool, payload: &Value) -> Result<ForgeEventKind> {
206 let (repo, forge_id) = repository(host, payload)?;
207 let pr = &payload["pull_request"];
208 let number = u64_field(pr, "number")?;
209 if number == 0 {
210 return Err(ForgeError::Webhook("pull request number 0".into()));
211 }
212 let author = account(&pr["user"])?;
213 let actor = account(&payload["sender"])?;
214 let draft = pr.get("draft").and_then(Value::as_bool);
215 let head = &pr["head"]["repo"];
219 let base = &pr["base"]["repo"];
220 let from_fork = if head.is_null() {
221 Some(true)
222 } else {
223 match (
224 head.get("id").and_then(Value::as_u64),
225 base.get("id").and_then(Value::as_u64),
226 ) {
227 (Some(h), Some(b)) => Some(h != b),
228 _ => match (
229 head.get("full_name").and_then(Value::as_str),
230 base.get("full_name").and_then(Value::as_str),
231 ) {
232 (Some(h), Some(b)) => Some(!h.eq_ignore_ascii_case(b)),
233 _ => None,
234 },
235 }
236 };
237 Ok(ForgeEventKind::PullRequestOpened {
238 repo,
239 forge_id,
240 number,
241 reopened,
242 author,
243 actor,
244 draft,
245 from_fork,
246 })
247}
248
249fn header_str<'a>(headers: &'a HeaderMap, name: &str) -> Result<Option<&'a str>> {
250 headers
251 .get(name)
252 .map(|v| {
253 v.to_str()
254 .map_err(|_| ForgeError::Webhook(format!("{name} is not ASCII")))
255 })
256 .transpose()
257}
258
259fn repository(host: &str, payload: &Value) -> Result<(Resource, u64)> {
260 let repo = &payload["repository"];
261 let full_name = str_at(repo, &["full_name"])?;
262 let resource = Resource::parse_owner_repo(&format!("{host}/{full_name}"))?;
263 Ok((resource, u64_field(repo, "id")?))
264}
265
266fn namespace(host: &str, account: &Value) -> Result<Resource> {
267 Resource::namespace_of(host, str_at(account, &["login"])?)
268}
269
270fn account(v: &Value) -> Result<ForgeAccount> {
271 Ok(ForgeAccount::new(
272 u64_field(v, "id")?,
273 str_at(v, &["login"])?,
274 ))
275}
276
277fn u64_field(v: &Value, key: &str) -> Result<u64> {
278 v.get(key)
279 .and_then(Value::as_u64)
280 .ok_or_else(|| ForgeError::Webhook(format!("payload is missing numeric `{key}`")))
281}
282
283fn str_at<'a>(v: &'a Value, path: &[&str]) -> Result<&'a str> {
284 path.iter()
285 .try_fold(v, |v, k| v.get(k))
286 .and_then(Value::as_str)
287 .ok_or_else(|| ForgeError::Webhook(format!("payload is missing `{}`", path.join("."))))
288}