Skip to content
320 linesCodeBlameRaw
1//! What a delivery is made of, apart from sending it: the payload, the
2//! signature, when to try again, and which addresses may be sent to.
3
4use g1t_contracts::events::Event;
5use g1t_contracts::webhooks::{EVENT_TYPES, RETIRED_EVENT_TYPES};
6use serde_json::{Value, json};
7
8/// How long to wait after each failed attempt before the next, so a
9/// delivery gets six attempts over about seven and a half hours.
10pub const RETRY_WAITS_SECONDS: [u64; 5] = [60, 5 * 60, 30 * 60, 2 * 60 * 60, 5 * 60 * 60];
11
12/// When to try again after `attempts` attempts, in seconds from now, or
13/// `None` when it has had them all.
14pub fn retry_after(attempts: u32) -> Option<u64> {
15 RETRY_WAITS_SECONDS.get(attempts.checked_sub(1)? as usize).copied()
16}
17
18/// Whether a webhook that wants `wanted` is sent an event of type `kind`.
19pub fn wants(wanted: &[String], kind: &str) -> bool {
20 wanted.iter().any(|event| event == "*" || event == kind)
21}
22
23/// Event types as given, checked and in catalogue order. `["*"]` when none
24/// or all are given. Types no longer sent ([`RETIRED_EVENT_TYPES`]), as a
25/// list saved before may hold, are left out. `Err` names the first that is
26/// not one, or says when only retired ones were given.
27pub fn tidy_events(given: &[String]) -> Result<Vec<String>, String> {
28 if given.is_empty() || given.iter().any(|event| event == "*") {
29 return Ok(vec!["*".to_owned()]);
30 }
31 let retired = |event: &String| RETIRED_EVENT_TYPES.contains(&event.as_str());
32 if given.iter().all(retired) {
33 return Err(format!("g1t no longer sends {}. Choose other events.", given.join(", ")));
34 }
35 let given: Vec<String> = given.iter().filter(|event| !retired(event)).cloned().collect();
36 if let Some(unknown) = given.iter().find(|event| !EVENT_TYPES.contains(&event.as_str())) {
37 return Err(format!("There is no event called {unknown}."));
38 }
39 Ok(EVENT_TYPES
40 .iter()
41 .filter(|kind| given.iter().any(|event| event == *kind))
42 .map(|kind| (*kind).to_owned())
43 .collect())
44}
45
46/// What is sent for an event: the event as the bus has it, with the
47/// repository, the workspace and whoever caused it named. Its keys are in
48/// `snake_case`, as everything g1t sends out is (see `g1t_kit::wire`).
49pub fn payload(event: &Event, workspace: &str, repo: Option<(&str, &str)>, actor_name: Option<&str>) -> Value {
50 g1t_kit::wire::snake_case(json!({
51 "id": event.id,
52 "type": event.kind,
53 "time": event.time,
54 "workspace": workspace,
55 "repository": repo.map(|(id, full_name)| json!({ "id": id, "full_name": full_name })),
56 "actor": event.actor.as_ref().map(|id| json!({ "id": id, "username": actor_name })),
57 "data": event.data,
58 }))
59}
60
61/// The repository a repository event names, as `(namespace, name)`, where
62/// it is now: for when repos no longer shows it (it was deleted or purged).
63pub fn named_in(event: &Event) -> Option<(String, String)> {
64 let text = |key: &str| event.data[key].as_str().filter(|value| !value.is_empty()).map(str::to_owned);
65 match event.kind.as_str() {
66 "repo.renamed" => Some((text("namespace")?, text("to")?)),
67 "repo.transferred" => Some((text("to")?, text("name")?)),
68 kind if kind.starts_with("repo.") => Some((text("namespace")?, text("name")?)),
69 _ => None,
70 }
71}
72
73/// The workspace an event belongs to when it is about no repository: a
74/// package of the workspace's own, unlinked from any repository, one of
75/// its teams, one of its rulesets, or someone joining it. Such an event
76/// goes to the workspace's webhooks only. Events about a repository (a
77/// team given a role on one among them, a repository's own ruleset), and
78/// every other kind, are `None`: they are routed by their repository.
79pub fn workspace_scoped(event: &Event) -> Option<String> {
80 let own = event.kind.starts_with("package.")
81 || event.kind.starts_with("team.")
82 || event.kind.starts_with("ruleset.")
83 || event.kind == "workspace.member_joined";
84 if event.repo_id.as_deref().is_some_and(|id| !id.is_empty()) || !own {
85 return None;
86 }
87 event.data["workspace"]
88 .as_str()
89 .map(|slug| slug.trim().to_lowercase())
90 .filter(|slug| !slug.is_empty())
91}
92
93/// What is sent to check a webhook works.
94pub fn ping(hook_id: &str, url: &str, events: &[String], time: &str) -> Value {
95 json!({
96 "type": "ping",
97 "time": time,
98 "hook": { "id": hook_id, "url": url, "events": events },
99 "message": "g1t will send this webhook's events here.",
100 })
101}
102
103/// The signature header's value: the body's HMAC-SHA256 under the secret.
104pub fn signature(secret: &str, body: &str) -> String {
105 format!("sha256={}", g1t_secrets::hmac_sha256_hex(secret, body))
106}
107
108/// Whether g1t may send to `url`: HTTPS, to a public host. `Err` says why
109/// not.
110pub fn check_url(url: &str) -> Result<(), String> {
111 let Some(rest) = url.strip_prefix("https://") else {
112 return Err("Webhooks are sent over HTTPS: the address must start with https://.".to_owned());
113 };
114 if url.len() > 2000 {
115 return Err("That address is too long.".to_owned());
116 }
117 let authority = rest.split(['/', '?', '#']).next().unwrap_or_default();
118 let host = authority.rsplit('@').next().unwrap_or_default();
119 let host = host.strip_prefix('[').map_or_else(
120 || host.split(':').next().unwrap_or_default(),
121 |v6| v6.split(']').next().unwrap_or_default(),
122 );
123 let host = host.to_ascii_lowercase();
124 if host.is_empty() || !host.contains(['.', ':']) {
125 return Err("The address needs a host g1t can reach on the internet.".to_owned());
126 }
127 let private_name = host == "localhost"
128 || [".localhost", ".local", ".internal", ".lan", ".home.arpa"]
129 .iter()
130 .any(|suffix| host.ends_with(suffix));
131 let private_ip = host.parse::<std::net::IpAddr>().is_ok_and(|ip| match ip {
132 std::net::IpAddr::V4(v4) => {
133 v4.is_private() || v4.is_loopback() || v4.is_link_local() || v4.is_unspecified() || v4.is_broadcast() || v4.octets()[0] == 100 && (64..128).contains(&v4.octets()[1])
134 }
135 std::net::IpAddr::V6(v6) => v6.is_loopback() || v6.is_unspecified() || (v6.segments()[0] & 0xfe00) == 0xfc00 || (v6.segments()[0] & 0xffc0) == 0xfe80,
136 });
137 if private_name || private_ip {
138 return Err("Webhooks go to public addresses, not private or local ones.".to_owned());
139 }
140 Ok(())
141}
142
143#[cfg(test)]
144mod tests {
145 use super::*;
146
147 #[test]
148 fn retries_wait_longer_each_time_then_stop() {
149 assert_eq!(retry_after(1), Some(60));
150 assert_eq!(retry_after(2), Some(300));
151 assert_eq!(retry_after(5), Some(18_000));
152 assert_eq!(retry_after(6), None);
153 assert_eq!(retry_after(0), None);
154 }
155
156 #[test]
157 fn events_are_checked_and_ordered() {
158 let given = vec!["pull.merged".to_owned(), "git.push".to_owned()];
159 assert_eq!(tidy_events(&given).unwrap(), vec!["git.push", "pull.merged"]);
160 assert_eq!(tidy_events(&[]).unwrap(), vec!["*"]);
161 assert!(tidy_events(&["pull.exploded".to_owned()]).is_err());
162 // A list saved when agent.asked was offered saves again without it.
163 let old = vec!["agent.asked".to_owned(), "pull.merged".to_owned(), "review.completed".to_owned()];
164 assert_eq!(tidy_events(&old).unwrap(), vec!["pull.merged"]);
165 assert!(tidy_events(&["agent.asked".to_owned()]).is_err());
166 assert!(wants(&["*".to_owned()], "issue.opened"));
167 assert!(!wants(&["git.push".to_owned()], "issue.opened"));
168 }
169
170 #[test]
171 fn only_public_https_addresses_are_allowed() {
172 assert!(check_url("https://hooks.example.com/g1t").is_ok());
173 assert!(check_url("https://example.com:8443/hook?x=1").is_ok());
174 for url in [
175 "http://example.com/hook",
176 "https://localhost/hook",
177 "https://127.0.0.1/hook",
178 "https://10.0.0.5/hook",
179 "https://192.168.1.2:8080/hook",
180 "https://169.254.169.254/latest",
181 "https://100.64.0.1/hook",
182 "https://[::1]/hook",
183 "https://[fd00::1]/hook",
184 "https://printer.local/hook",
185 "https://intranet/hook",
186 "https://user@10.0.0.1/hook",
187 ] {
188 assert!(check_url(url).is_err(), "{url}");
189 }
190 }
191
192 #[test]
193 fn payloads_are_snake_case() {
194 let event = Event {
195 id: "evt_1".to_owned(),
196 kind: "pull.merged".to_owned(),
197 source: "work".to_owned(),
198 time: "2026-10-04T16:00:00Z".to_owned(),
199 repo_id: Some("rep_1".to_owned()),
200 actor: Some("usr_1".to_owned()),
201 data: json!({ "pullId": "pul_1", "repoId": "rep_1", "supersededBy": null, "inputs": { "dryRun": true } }),
202 };
203 let sent = payload(&event, "acme", Some(("rep_1", "acme/rocket")), Some("syntaqx"));
204 assert_eq!(sent["repository"]["full_name"], "acme/rocket");
205 assert_eq!(sent["data"]["pull_id"], "pul_1");
206 assert!(sent["data"].get("superseded_by").is_some());
207 assert_eq!(sent["data"]["inputs"]["dryRun"], true);
208 assert!(g1t_kit::wire::camel_case_keys(&sent).is_empty());
209 }
210
211 #[test]
212 fn a_pull_request_g1t_made_names_g1t_and_who_asked() {
213 // As the work service publishes it: g1t the author, the person who
214 // asked beside it, and the actor whoever caused the event.
215 let event = Event {
216 id: "evt_1".to_owned(),
217 kind: "pull.opened".to_owned(),
218 source: "work".to_owned(),
219 time: "2026-10-06T10:00:00Z".to_owned(),
220 repo_id: Some("rep_1".to_owned()),
221 actor: Some("usr_1".to_owned()),
222 data: json!({
223 "pullId": "pr_1", "repoId": "rep_1", "number": 14, "agent": "g1t",
224 "author": { "id": "usr_g1t_agent", "username": "g1t" },
225 "requestedBy": { "id": "usr_1", "username": "syntaqx" }
226 }),
227 };
228 let sent = payload(&event, "acme", Some(("rep_1", "acme/rocket")), Some("syntaqx"));
229 assert_eq!(sent["data"]["author"]["username"], "g1t");
230 assert_eq!(sent["data"]["requested_by"]["username"], "syntaqx");
231 assert_eq!(sent["actor"]["username"], "syntaqx");
232 }
233
234 fn repo_event(kind: &str, data: Value) -> Event {
235 Event {
236 id: "evt_1".to_owned(),
237 kind: kind.to_owned(),
238 source: "repos".to_owned(),
239 time: "2026-10-05T16:00:00Z".to_owned(),
240 repo_id: Some("rep_1".to_owned()),
241 actor: None,
242 data,
243 }
244 }
245
246 #[test]
247 fn a_workspaces_own_package_events_go_to_its_webhooks() {
248 let mut event = repo_event("package.published", json!({ "packageId": "pkg_1", "workspace": "Acme", "name": "tools", "repoId": null }));
249 event.repo_id = None;
250 assert_eq!(workspace_scoped(&event).as_deref(), Some("acme"));
251 let sent = payload(&event, "acme", None, Some("ana"));
252 assert_eq!(sent["repository"], Value::Null);
253 assert_eq!(sent["workspace"], "acme");
254 assert_eq!(sent["data"]["package_id"], "pkg_1", "snake_case as everything sent");
255 // A linked package's events go by its repository.
256 let linked = repo_event("package.published", json!({ "workspace": "acme", "repoId": "rep_1" }));
257 assert_eq!(workspace_scoped(&linked), None);
258 // A team's events are the workspace's; one about a repository goes by it.
259 let mut team = repo_event("team.created", json!({ "workspace": "Acme", "team": "backend" }));
260 team.repo_id = None;
261 assert_eq!(workspace_scoped(&team).as_deref(), Some("acme"));
262 let granted = repo_event("team.repo_added", json!({ "workspace": "acme", "repoId": "rep_1" }));
263 assert_eq!(workspace_scoped(&granted), None);
264 // A workspace's ruleset is the workspace's; a repository's goes by it.
265 let mut ruleset = repo_event("ruleset.updated", json!({ "workspace": "acme", "ruleset": {} }));
266 ruleset.repo_id = None;
267 assert_eq!(workspace_scoped(&ruleset).as_deref(), Some("acme"));
268 let own = repo_event("ruleset.created", json!({ "workspace": "acme", "repository": "acme/web" }));
269 assert_eq!(workspace_scoped(&own), None);
270 // Someone joining the workspace is the workspace's.
271 let mut joined = repo_event("workspace.member_joined", json!({ "workspaceId": "wsp_1", "workspace": "Acme", "userId": "usr_1", "username": "ada", "role": "member", "how": "invitation" }));
272 joined.repo_id = None;
273 assert_eq!(workspace_scoped(&joined).as_deref(), Some("acme"));
274 assert_eq!(payload(&joined, "acme", None, Some("ada"))["data"]["user_id"], "usr_1", "snake_case as everything sent");
275 // Other events without a repository are not workspace events.
276 let mut other = repo_event("issue.opened", json!({ "workspace": "acme" }));
277 other.repo_id = None;
278 assert_eq!(workspace_scoped(&other), None);
279 let mut nameless = repo_event("package.deleted", json!({ "workspace": "" }));
280 nameless.repo_id = None;
281 assert_eq!(workspace_scoped(&nameless), None);
282 }
283
284 #[test]
285 fn repository_events_name_where_it_is_now() {
286 let named = |kind: &str, data: Value| named_in(&repo_event(kind, data));
287 let at = |namespace: &str, name: &str| Some((namespace.to_owned(), name.to_owned()));
288 assert_eq!(named("repo.renamed", json!({ "namespace": "acme", "from": "old", "to": "new" })), at("acme", "new"));
289 assert_eq!(named("repo.transferred", json!({ "name": "web", "from": "a", "to": "b" })), at("b", "web"));
290 assert_eq!(named("repo.purged", json!({ "repoId": "rep_1", "namespace": "acme", "name": "web" })), at("acme", "web"));
291 assert_eq!(named("repo.deleted", json!({ "repoId": "rep_1", "namespace": "", "name": "web" })), None);
292 assert_eq!(named("issue.opened", json!({ "namespace": "acme", "name": "web" })), None);
293 }
294
295 #[test]
296 fn repository_lifecycle_events_can_be_chosen() {
297 for kind in [
298 "repo.updated",
299 "repo.visibility_changed",
300 "repo.renamed",
301 "repo.transferred",
302 "repo.archived",
303 "repo.unarchived",
304 "repo.deleted",
305 "repo.restored",
306 "repo.purged",
307 "repo.default_branch_changed",
308 "branch.renamed",
309 ] {
310 assert_eq!(tidy_events(&[kind.to_owned()]).unwrap(), vec![kind], "{kind}");
311 }
312 }
313
314 #[test]
315 fn the_signature_is_the_bodys_hmac() {
316 let signature = signature("shh", "{\"a\":1}");
317 assert!(signature.starts_with("sha256="));
318 assert!(g1t_secrets::signed("shh", "{\"a\":1}", &signature));
319 }
320}