Skip to main content

vgi_forge_github/
checks.rs

1//! The check the bridge posts itself (§9, "forged check runs").
2//!
3//! Where no org required workflow is available, a check pinned to the GitHub
4//! Actions App is forgeable: any writer can run a workflow on another branch
5//! that posts a passing "Verify commit trust" run onto someone else's pull
6//! request. So the bridge runs verify-trust itself and posts the check as
7//! the community's App, and the ruleset requires the check from that App
8//! ([`crate::GitHubConfig::bridge_checks`]).
9//!
10//! This module is the GitHub half of that: recognising the deliveries that
11//! call for a check ([`GitHubForge::parse_check_trigger`], signature first),
12//! listing the commits under test ([`GitHubForge::compare_commits`]), a
13//! read-only token for fetching them ([`GitHubForge::contents_read_token`]),
14//! and the check run itself ([`GitHubForge::start_check_run`],
15//! [`GitHubForge::finish_check_run`]). Running verify-trust is the bridge's.
16//!
17//! Every token here is minted for the one repository and the one permission
18//! the call needs, and dropped on return — except the contents token, which
19//! the caller holds for exactly one fetch.
20
21use http::HeaderMap;
22use reqwest::Method;
23use serde::Deserialize;
24use serde_json::{Value, json};
25use url::Url;
26use vgi_forge::{ForgeError, Resource, Result};
27
28use crate::api::Auth;
29use crate::forge::GitHubForge;
30use crate::secret::Secret;
31use crate::webhook;
32
33/// Posting and updating check runs.
34const PERMS_CHECKS: &[(&str, &str)] = &[("checks", "write"), ("metadata", "read")];
35/// Listing and fetching the commits under test. Read-only: the bridge never
36/// writes to a repository while checking it.
37const PERMS_READ: &[(&str, &str)] = &[("contents", "read"), ("metadata", "read")];
38/// Reading a pull request's current head and base.
39const PERMS_PULLS: &[(&str, &str)] = &[("metadata", "read"), ("pull_requests", "read")];
40/// The repository's metadata (its default branch).
41const PERMS_METADATA: &[(&str, &str)] = &[("metadata", "read")];
42/// GitHub lists a comparison's commits 100 to a page, 250 in all.
43const COMPARE_PER_PAGE: usize = 100;
44const COMPARE_PAGES: usize = 3;
45
46/// GitHub caps a check run's `output.summary` at 65 535 characters.
47const MAX_SUMMARY: usize = 65_000;
48
49/// Why a check is due.
50#[derive(Debug, Clone, PartialEq, Eq)]
51#[non_exhaustive]
52pub enum CheckTriggerKind {
53    /// A pull request was opened, pushed to, reopened, or had its base
54    /// branch changed.
55    PullRequest {
56        /// Its number.
57        number: u64,
58    },
59    /// A merge queue asked for checks on a merge group.
60    MergeGroup,
61    /// Someone asked for the check again (`check_run` / `check_suite`
62    /// `rerequested`) on a pull request.
63    Rerequested {
64        /// The pull request.
65        number: u64,
66    },
67}
68
69/// A verified delivery that calls for the check on `head_sha`, against the
70/// base branch `base_ref`.
71///
72/// A check run attaches to a *commit*, not to a pull request: a success on
73/// `head_sha` satisfies every pull request whose head is that commit. So a
74/// check is only meaningful — and must only be posted — against a base the
75/// required check protects; the caller decides that from `base_ref`, never
76/// from anything else in the delivery.
77#[derive(Debug, Clone, PartialEq, Eq)]
78#[non_exhaustive]
79pub struct CheckTrigger {
80    /// The repository the check is posted on (the pull request's base).
81    pub repo: Resource,
82    /// Its forge id.
83    pub repo_id: u64,
84    /// The commit the check is for.
85    pub head_sha: String,
86    /// The base branch's tip the delivery named (a pull request's may be
87    /// stale; the caller re-reads it).
88    pub base_sha: String,
89    /// The base branch, without `refs/heads/`.
90    pub base_ref: String,
91    /// Pull request, merge group or rerequest.
92    pub kind: CheckTriggerKind,
93    /// GitHub's delivery id, for de-duplication.
94    pub delivery_id: Option<String>,
95    /// For a pull request delivery: its head branch, as the delivery says —
96    /// a hint for the Dependabot re-sign, which re-reads it from GitHub.
97    pub head_ref: Option<String>,
98    /// For a pull request delivery: who opened it, as the delivery says (a
99    /// hint only, like `head_ref`).
100    pub author_login: Option<String>,
101}
102
103/// A pull request as GitHub reports it now.
104#[derive(Debug, Clone, PartialEq, Eq)]
105#[non_exhaustive]
106pub struct PullRequestInfo {
107    /// Its head commit.
108    pub head_sha: String,
109    /// Its base branch, without `refs/heads/`.
110    pub base_ref: String,
111    /// The base branch's tip.
112    pub base_sha: String,
113    /// Open (a closed or merged one gets no new check).
114    pub open: bool,
115    /// Its head branch, without `refs/heads/`.
116    pub head_ref: String,
117    /// The repository its head branch is in; `None` when GitHub reports
118    /// none (a deleted fork).
119    pub head_repo_id: Option<u64>,
120    /// The repository it merges into.
121    pub base_repo_id: Option<u64>,
122    /// The login of the account that opened it.
123    pub author_login: Option<String>,
124    /// That account's numeric id.
125    pub author_id: Option<u64>,
126}
127
128/// How a check run ended.
129#[derive(Debug, Clone, Copy, PartialEq, Eq)]
130#[non_exhaustive]
131pub enum CheckConclusion {
132    /// Every commit passed.
133    Success,
134    /// At least one commit did not, or the check could not be completed —
135    /// fail closed, the same as verify-trust's exit code.
136    Failure,
137}
138
139impl CheckConclusion {
140    fn as_str(self) -> &'static str {
141        match self {
142            CheckConclusion::Success => "success",
143            CheckConclusion::Failure => "failure",
144        }
145    }
146}
147
148/// The commits between a base and a head, as GitHub lists them.
149#[derive(Debug, Clone, PartialEq, Eq)]
150#[non_exhaustive]
151pub struct Comparison {
152    /// The commits in `base...head` (reachable from head, not from the merge
153    /// base), oldest first. GitHub lists at most 250.
154    pub commits: Vec<String>,
155    /// How many there are in all. More than `commits.len()` means the list
156    /// was truncated and the range cannot be checked from it.
157    pub total: u64,
158    /// The merge base.
159    pub merge_base: String,
160}
161
162/// A branch name from a ref (`refs/heads/main` → `main`); a name that is
163/// already bare is returned as it is.
164fn branch_of(r: &str) -> &str {
165    r.strip_prefix("refs/heads/").unwrap_or(r)
166}
167
168/// The `pull_requests` entries of a `check_run` / `check_suite` payload, as
169/// rerequest triggers.
170fn rerequests(
171    repo: &Resource,
172    repo_id: u64,
173    head_sha: &str,
174    prs: Option<&Value>,
175    delivery_id: &Option<String>,
176) -> Vec<CheckTrigger> {
177    let mut out = Vec::new();
178    for pr in prs.and_then(Value::as_array).into_iter().flatten() {
179        let Some(number) = pr.get("number").and_then(Value::as_u64) else {
180            continue;
181        };
182        let base_ref = pr
183            .pointer("/base/ref")
184            .and_then(Value::as_str)
185            .unwrap_or_default();
186        let base_sha = pr
187            .pointer("/base/sha")
188            .and_then(Value::as_str)
189            .unwrap_or_default();
190        if base_ref.is_empty() || check_sha(base_sha).is_err() {
191            continue;
192        }
193        out.push(CheckTrigger {
194            repo: repo.clone(),
195            repo_id,
196            head_sha: head_sha.to_string(),
197            base_sha: base_sha.to_string(),
198            base_ref: branch_of(base_ref).to_string(),
199            kind: CheckTriggerKind::Rerequested { number },
200            delivery_id: delivery_id.clone(),
201            head_ref: None,
202            author_login: None,
203        });
204    }
205    out
206}
207
208impl GitHubForge {
209    /// Verify a webhook and, if it calls for the bridge-posted check, say on
210    /// what. Empty for a verified delivery that does not (another event, a
211    /// closed pull request, a title edit). `Err` for one that failed
212    /// verification, which must not be acted on.
213    ///
214    /// Triggers: `pull_request` `opened` / `synchronize` / `reopened`, and
215    /// `edited` when the base branch changed; `merge_group`
216    /// `checks_requested`; this App's `check_run` / `check_suite`
217    /// `rerequested` (one trigger per pull request the delivery names). The
218    /// repository is the delivery's `repository` — the base the check is
219    /// posted on — never the fork a pull request came from.
220    pub fn parse_check_trigger(
221        &self,
222        headers: &HeaderMap,
223        body: &[u8],
224    ) -> Result<Vec<CheckTrigger>> {
225        webhook::verify_signature(self.webhook_secret(), headers, body)?;
226        let event = headers
227            .get("x-github-event")
228            .and_then(|v| v.to_str().ok())
229            .ok_or_else(|| ForgeError::Webhook("missing X-GitHub-Event".into()))?;
230        let delivery_id = headers
231            .get("x-github-delivery")
232            .and_then(|v| v.to_str().ok())
233            .map(str::to_string);
234        if !matches!(
235            event,
236            "pull_request" | "merge_group" | "check_run" | "check_suite"
237        ) {
238            return Ok(Vec::new());
239        }
240        let payload: Value = serde_json::from_slice(body)
241            .map_err(|e| ForgeError::Webhook(format!("body is not JSON: {e}")))?;
242        let action = payload.get("action").and_then(Value::as_str).unwrap_or("");
243        let repo_json = &payload["repository"];
244        let full_name = repo_json
245            .get("full_name")
246            .and_then(Value::as_str)
247            .ok_or_else(|| {
248                ForgeError::Webhook("payload is missing `repository.full_name`".into())
249            })?;
250        let repo = Resource::parse_owner_repo(&format!("{}/{full_name}", self.config().host))?;
251        let repo_id = repo_json
252            .get("id")
253            .and_then(Value::as_u64)
254            .ok_or_else(|| ForgeError::Webhook("payload is missing `repository.id`".into()))?;
255
256        let one = |head_sha: String, base_sha: String, base_ref: &str, kind| {
257            vec![CheckTrigger {
258                repo: repo.clone(),
259                repo_id,
260                head_sha,
261                base_sha,
262                base_ref: branch_of(base_ref).to_string(),
263                kind,
264                delivery_id: delivery_id.clone(),
265                head_ref: None,
266                author_login: None,
267            }]
268        };
269        let app_id = self.config().app_id;
270        Ok(match (event, action) {
271            ("pull_request", "opened" | "synchronize" | "reopened" | "edited") => {
272                // An edit matters only when it moved the base: a title edit
273                // changes nothing the check depends on.
274                if action == "edited" && payload.pointer("/changes/base").is_none() {
275                    return Ok(Vec::new());
276                }
277                let pr = &payload["pull_request"];
278                let number = pr
279                    .get("number")
280                    .and_then(Value::as_u64)
281                    .ok_or_else(|| ForgeError::Webhook("pull request has no number".into()))?;
282                let mut t = one(
283                    sha_at(pr, &["head", "sha"])?,
284                    sha_at(pr, &["base", "sha"])?,
285                    str_at(pr, &["base", "ref"])?,
286                    CheckTriggerKind::PullRequest { number },
287                );
288                t[0].head_ref = str_at(pr, &["head", "ref"])
289                    .ok()
290                    .map(|r| branch_of(r).to_string());
291                t[0].author_login = str_at(pr, &["user", "login"]).ok().map(str::to_string);
292                t
293            }
294            ("merge_group", "checks_requested") => {
295                let group = &payload["merge_group"];
296                one(
297                    sha_at(group, &["head_sha"])?,
298                    sha_at(group, &["base_sha"])?,
299                    str_at(group, &["base_ref"])?,
300                    CheckTriggerKind::MergeGroup,
301                )
302            }
303            ("check_run", "rerequested") | ("check_suite", "rerequested") => {
304                let obj = &payload[event];
305                // Only this App's own runs: another App's rerequest is not
306                // ours to answer.
307                if obj.pointer("/app/id").and_then(Value::as_u64) != Some(app_id) {
308                    return Ok(Vec::new());
309                }
310                let head = sha_at(obj, &["head_sha"])?;
311                rerequests(
312                    &repo,
313                    repo_id,
314                    &head,
315                    obj.get("pull_requests"),
316                    &delivery_id,
317                )
318            }
319            _ => Vec::new(),
320        })
321    }
322
323    /// The repository's default branch — the one branch the managed
324    /// ruleset protects (`~DEFAULT_BRANCH`), read from GitHub now rather
325    /// than from a delivery.
326    pub async fn default_branch(&self, repo: &Resource) -> Result<String> {
327        let (token, owner, name) = self.repo_token_for(repo, PERMS_METADATA).await?;
328        #[derive(Deserialize)]
329        struct R {
330            default_branch: Option<String>,
331        }
332        let r: R = self
333            .api()
334            .json(
335                Method::GET,
336                self.api().url(&["repos", &owner, &name]),
337                Auth::Bearer(&token),
338                None,
339                repo.as_str(),
340            )
341            .await?;
342        r.default_branch
343            .ok_or_else(|| ForgeError::Protocol(format!("`{repo}` has no default branch")))
344    }
345
346    /// Pull request `number` on `repo`, as GitHub reports it now.
347    pub async fn pull_request(&self, repo: &Resource, number: u64) -> Result<PullRequestInfo> {
348        let (token, owner, name) = self.repo_token_for(repo, PERMS_PULLS).await?;
349        let pr: Value = self
350            .api()
351            .json(
352                Method::GET,
353                self.api()
354                    .url(&["repos", &owner, &name, "pulls", &number.to_string()]),
355                Auth::Bearer(&token),
356                None,
357                "pull request",
358            )
359            .await?;
360        Ok(PullRequestInfo {
361            head_sha: sha_at(&pr, &["head", "sha"])?,
362            base_ref: branch_of(str_at(&pr, &["base", "ref"])?).to_string(),
363            base_sha: sha_at(&pr, &["base", "sha"])?,
364            open: pr.get("state").and_then(Value::as_str) == Some("open"),
365            head_ref: pr
366                .pointer("/head/ref")
367                .and_then(Value::as_str)
368                .map(|r| branch_of(r).to_string())
369                .unwrap_or_default(),
370            head_repo_id: pr.pointer("/head/repo/id").and_then(Value::as_u64),
371            base_repo_id: pr.pointer("/base/repo/id").and_then(Value::as_u64),
372            author_login: pr
373                .pointer("/user/login")
374                .and_then(Value::as_str)
375                .map(str::to_string),
376            author_id: pr.pointer("/user/id").and_then(Value::as_u64),
377        })
378    }
379
380    /// The commits in `base...head` on `repo`, oldest first
381    /// (`GET /repos/{o}/{r}/compare/{base}...{head}`, every page: GitHub
382    /// lists 100 per page and 250 in all).
383    pub async fn compare_commits(
384        &self,
385        repo: &Resource,
386        base: &str,
387        head: &str,
388    ) -> Result<Comparison> {
389        check_sha(base)?;
390        check_sha(head)?;
391        let (token, owner, name) = self.repo_token_for(repo, PERMS_READ).await?;
392        #[derive(Deserialize)]
393        struct Sha {
394            sha: String,
395        }
396        #[derive(Deserialize)]
397        struct Compare {
398            total_commits: u64,
399            merge_base_commit: Sha,
400            #[serde(default)]
401            commits: Vec<Sha>,
402        }
403        let range = format!("{base}...{head}");
404        let mut commits = Vec::new();
405        let mut total = 0;
406        let mut merge_base = String::new();
407        for page in 1..=COMPARE_PAGES {
408            let mut url = self.api().url(&["repos", &owner, &name, "compare", &range]);
409            url.query_pairs_mut()
410                .append_pair("per_page", &COMPARE_PER_PAGE.to_string())
411                .append_pair("page", &page.to_string());
412            let c: Compare = self
413                .api()
414                .json(
415                    Method::GET,
416                    url,
417                    Auth::Bearer(&token),
418                    None,
419                    "commit comparison",
420                )
421                .await?;
422            total = c.total_commits;
423            merge_base = c.merge_base_commit.sha;
424            let n = c.commits.len();
425            commits.extend(c.commits.into_iter().map(|s| s.sha));
426            if n < COMPARE_PER_PAGE || commits.len() as u64 >= total {
427                break;
428            }
429        }
430        for sha in &commits {
431            check_sha(sha)?;
432        }
433        Ok(Comparison {
434            commits,
435            total,
436            merge_base,
437        })
438    }
439
440    /// The merge base of `a` and `b` on `repo`: GitHub's
441    /// `merge_base_commit` for `a...b`, one commit listed at most. For
442    /// recomputing a merge from a fetch too shallow to search for the base.
443    pub async fn merge_base(&self, repo: &Resource, a: &str, b: &str) -> Result<String> {
444        check_sha(a)?;
445        check_sha(b)?;
446        let (token, owner, name) = self.repo_token_for(repo, PERMS_READ).await?;
447        #[derive(Deserialize)]
448        struct Sha {
449            sha: String,
450        }
451        #[derive(Deserialize)]
452        struct Compare {
453            merge_base_commit: Sha,
454        }
455        let range = format!("{a}...{b}");
456        let mut url = self.api().url(&["repos", &owner, &name, "compare", &range]);
457        url.query_pairs_mut().append_pair("per_page", "1");
458        let c: Compare = self
459            .api()
460            .json(Method::GET, url, Auth::Bearer(&token), None, "merge base")
461            .await?;
462        check_sha(&c.merge_base_commit.sha)?;
463        Ok(c.merge_base_commit.sha)
464    }
465
466    /// A token that can read (fetch) `repo` and nothing else, for one fetch
467    /// of the commits under test. The caller holds it for that fetch only
468    /// and drops it; it lapses on its own within the hour either way.
469    pub async fn contents_read_token(&self, repo: &Resource) -> Result<Secret> {
470        Ok(self.repo_token_for(repo, PERMS_READ).await?.0)
471    }
472
473    /// The HTTPS clone URL of `repo` on this GitHub (`<web>/<owner>/<repo>.git`).
474    pub fn clone_url(&self, repo: &Resource) -> Result<Url> {
475        repo.require_owner_repo()?;
476        let name = repo.repo_name().ok_or_else(|| ForgeError::WrongResource {
477            resource: repo.to_string(),
478            expected: "a repository".into(),
479        })?;
480        Ok(self.api().web_url(&[repo.owner(), &format!("{name}.git")]))
481    }
482
483    /// Post the check on `head_sha` as in progress, and return its id.
484    pub async fn start_check_run(
485        &self,
486        repo: &Resource,
487        head_sha: &str,
488        name: &str,
489        external_id: &str,
490    ) -> Result<u64> {
491        check_sha(head_sha)?;
492        let (token, owner, repo_name) = self.repo_token_for(repo, PERMS_CHECKS).await?;
493        #[derive(Deserialize)]
494        struct Created {
495            id: u64,
496        }
497        let body = json!({
498            "name": name,
499            "head_sha": head_sha,
500            "status": "in_progress",
501            "external_id": external_id,
502            "output": {
503                "title": "Checking commit signatures",
504                "summary": "The community's bridge is verifying every commit against the Trust Registry.",
505            },
506        });
507        let created: Created = self
508            .api()
509            .json(
510                Method::POST,
511                self.api().url(&["repos", &owner, &repo_name, "check-runs"]),
512                Auth::Bearer(&token),
513                Some(&body),
514                "check run",
515            )
516            .await?;
517        Ok(created.id)
518    }
519
520    /// Complete check run `id` with `conclusion`, a one-line `title` and a
521    /// Markdown `summary` (truncated to what GitHub accepts).
522    pub async fn finish_check_run(
523        &self,
524        repo: &Resource,
525        id: u64,
526        conclusion: CheckConclusion,
527        title: &str,
528        summary: &str,
529    ) -> Result<()> {
530        let (token, owner, repo_name) = self.repo_token_for(repo, PERMS_CHECKS).await?;
531        let body = json!({
532            "status": "completed",
533            "conclusion": conclusion.as_str(),
534            "output": { "title": title, "summary": truncate(summary, MAX_SUMMARY) },
535        });
536        self.api()
537            .send(
538                Method::PATCH,
539                self.api()
540                    .url(&["repos", &owner, &repo_name, "check-runs", &id.to_string()]),
541                Auth::Bearer(&token),
542                Some(&body),
543                "check run",
544            )
545            .await?;
546        Ok(())
547    }
548}
549
550/// A git object id: 40 (SHA-1) or 64 (SHA-256) lowercase hex. Checked before
551/// it goes into a URL or a git command line.
552pub fn check_sha(sha: &str) -> Result<()> {
553    let ok = (sha.len() == 40 || sha.len() == 64)
554        && sha
555            .bytes()
556            .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b));
557    if ok {
558        Ok(())
559    } else {
560        Err(ForgeError::Protocol(format!("`{sha}` is not a commit id")))
561    }
562}
563
564fn sha_at(v: &Value, path: &[&str]) -> Result<String> {
565    let s = path
566        .iter()
567        .try_fold(v, |v, k| v.get(k))
568        .and_then(Value::as_str)
569        .ok_or_else(|| ForgeError::Webhook(format!("payload is missing `{}`", path.join("."))))?;
570    check_sha(s)
571        .map_err(|_| ForgeError::Webhook(format!("`{}` is not a commit id", path.join("."))))?;
572    Ok(s.to_string())
573}
574
575fn str_at<'a>(v: &'a Value, path: &[&str]) -> Result<&'a str> {
576    path.iter()
577        .try_fold(v, |v, k| v.get(k))
578        .and_then(Value::as_str)
579        .filter(|s| !s.is_empty())
580        .ok_or_else(|| ForgeError::Webhook(format!("payload is missing `{}`", path.join("."))))
581}
582
583fn truncate(s: &str, max: usize) -> String {
584    if s.len() <= max {
585        return s.to_string();
586    }
587    let mut end = max;
588    while !s.is_char_boundary(end) {
589        end -= 1;
590    }
591    format!("{}\n\n… (truncated)", &s[..end])
592}
593
594#[cfg(test)]
595mod tests {
596    use super::*;
597
598    #[test]
599    fn shas_are_hex_of_the_right_length() {
600        assert!(check_sha(&"a".repeat(40)).is_ok());
601        assert!(check_sha(&"0".repeat(64)).is_ok());
602        for bad in [
603            "",
604            "abc",
605            &"A".repeat(40),
606            &"g".repeat(40),
607            "--upload-pack=x",
608        ] {
609            assert!(check_sha(bad).is_err(), "{bad}");
610        }
611    }
612
613    #[test]
614    fn long_summaries_are_cut_on_a_char_boundary() {
615        let s = "é".repeat(40_000);
616        let t = truncate(&s, MAX_SUMMARY);
617        assert!(t.len() < s.len());
618        assert!(t.ends_with("(truncated)"));
619    }
620}