Rulesets on push: refused with the ruleset and rule named, commits read
The repos service enforces rulesets on every push and on every other change to a branch or tag made through g1t, asking the work service which hold (ref_rules) and sending back how each judged it (record_evaluations). - Pushes: git is told, as remote: lines, which ruleset and rule refused the push, what is wrong and how to fix it, and where the branch's rules are shown. Rules about commits read the pushed pack: messages, addresses, parents, files added, changed and deleted with their sizes, and signatures. Force pushes are told apart by walking the pack and the repository's history. A push too large to read cannot meet a rule about commits, and with "Block pushes that add secrets" a push too large to scan is refused rather than let through unscanned. - Signed commits: ed25519 SSH signatures (git's gpg.format ssh) are verified, and count only when the key is registered on the account that owns the committer's verified address (identity ssh_key_owners, new). GPG and other key types are reported as not verified yet. - Renaming a branch (a deletion and a creation), a commit made on the site, and bringing a pull request's branch up to date obey the rules of their branches. - inspect_commits: the commits a pull request would land, fetched from its source as a pack and read the same way, for rules at merge time. - Updating a repository's protected flag sets its branch protection ruleset. Without the work service, the old flag still holds on push. - Restricting updates restricts merges too. - ruleset.created, ruleset.updated and ruleset.deleted webhook events; a workspace's go to the workspace's webhooks.
| 1062 | 1062 | version = "0.1.0" | |
| 1063 | 1063 | dependencies = [ | |
| 1064 | 1064 | "base64 0.22.1", | |
| 1065 | + | "ed25519-dalek", | |
| 1065 | 1066 | "futures-util", | |
| 1066 | 1067 | "g1t-blobstore", | |
| 1067 | 1068 | "g1t-contracts", | |
| 1068 | 1069 | "g1t-kit", | |
| 1070 | + | "g1t-rules", | |
| 1069 | 1071 | "g1t-scan", | |
| 1070 | 1072 | "g1t-secrets", | |
| 1071 | 1073 | "miniz_oxide", | |
| 1072 | 1074 | "serde", | |
| 1073 | 1075 | "serde_json", | |
| 1076 | + | "sha2 0.10.9", | |
| 1074 | 1077 | "similar", | |
| 1075 | 1078 | "worker", | |
| 1076 | 1079 | ] |
| 107 | 107 | pub user: User, | |
| 108 | 108 | } | |
| 109 | 109 | ||
| 110 | + | /// `ssh_key_owners`: services only. The account (user id) that registered | |
| 111 | + | /// each key, by fingerprint (`SHA256:…`, as `ssh-keygen -lf` prints it), | |
| 112 | + | /// for verifying commits signed with SSH keys. Returns a map of the | |
| 113 | + | /// fingerprints found to user ids. | |
| 114 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 115 | + | pub struct SshKeyOwnersArgs { | |
| 116 | + | pub fingerprints: Vec<String>, | |
| 117 | + | } | |
| 118 | + | ||
| 110 | 119 | /// `add_ssh_key`: `public_key` is one line in OpenSSH format. | |
| 111 | 120 | /// Returns `Outcome<SshKey>`. | |
| 112 | 121 | #[derive(Debug, Serialize, Deserialize)] |
| 15 | 15 | use crate::{User, Viewer}; | |
| 16 | 16 | ||
| 17 | 17 | /// Every event a webhook can be sent, in the order people are shown them. | |
| 18 | − | pub const EVENT_TYPES: [&str; 78] = [ | |
| 18 | + | pub const EVENT_TYPES: [&str; 81] = [ | |
| 19 | 19 | "git.push", | |
| 20 | 20 | "branch.renamed", | |
| 21 | 21 | "repo.created", | |
| ⋯ | |||
| 42 | 42 | "team.repo_added", | |
| 43 | 43 | "team.repo_role_changed", | |
| 44 | 44 | "team.repo_removed", | |
| 45 | + | "ruleset.created", | |
| 46 | + | "ruleset.updated", | |
| 47 | + | "ruleset.deleted", | |
| 45 | 48 | "issue.opened", | |
| 46 | 49 | "issue.updated", | |
| 47 | 50 | "issue.assigned", | |
| 247 | 247 | /// The problems one rule finds in a merge. | |
| 248 | 248 | fn rule_problems(rule: &Rule, facts: &MergeFacts<'_>) -> Vec<Problem> { | |
| 249 | 249 | match rule { | |
| 250 | + | // Restricting updates restricts merges too: a merge moves the branch. | |
| 251 | + | Rule::Update(_) => { | |
| 252 | + | let branch = facts.git_ref.strip_prefix("refs/heads/").unwrap_or(&facts.git_ref); | |
| 253 | + | vec![Problem::new( | |
| 254 | + | format!("Only people this ruleset lets bypass it may change {branch}, merges included."), | |
| 255 | + | "Ask someone who may bypass this ruleset to merge it.", | |
| 256 | + | )] | |
| 257 | + | } | |
| 250 | 258 | Rule::PullRequest(rule) => pull_request_problems(rule, facts), | |
| 251 | 259 | Rule::RequiredStatusChecks(rule) => checks_problems(rule, facts), | |
| 252 | 260 | Rule::RequiredDeployments(rule) => rule | |
| ⋯ | |||
| 801 | 809 | } | |
| 802 | 810 | ||
| 803 | 811 | #[test] | |
| 812 | + | fn restricting_updates_restricts_merges() { | |
| 813 | + | let rules = [ruleset(vec![all(Rule::Update(NoParameters {}))])]; | |
| 814 | + | assert_eq!( | |
| 815 | + | messages(&judge(&rules, "main", &facts(&[], &[], &[]))), | |
| 816 | + | vec!["Only people this ruleset lets bypass it may change main, merges included."] | |
| 817 | + | ); | |
| 818 | + | let mut bypassed = rules[0].clone(); | |
| 819 | + | bypassed.bypass = Some(BypassMode::Always); | |
| 820 | + | assert!(!refused(&judge(&[bypassed], "main", &facts(&[], &[], &[])))); | |
| 821 | + | } | |
| 822 | + | ||
| 823 | + | #[test] | |
| 804 | 824 | fn a_merge_freeze_holds_merges() { | |
| 805 | 825 | let rules = [ruleset(vec![all(Rule::MergeWindow(MergeWindowRule { | |
| 806 | 826 | freezes: vec![Period { start: "1970-01-01T00:00:00Z".into(), end: None, reason: "Incident".into() }], | |
| 35 | 35 | "team.repo_added", | |
| 36 | 36 | "team.repo_role_changed", | |
| 37 | 37 | "team.repo_removed", | |
| 38 | + | "ruleset.created", | |
| 39 | + | "ruleset.updated", | |
| 40 | + | "ruleset.deleted", | |
| 38 | 41 | "issue.opened", | |
| 39 | 42 | "issue.updated", | |
| 40 | 43 | "issue.assigned", |
| 98 | 98 | ||
| 99 | 99 | test("shared crates and packages are read from workspace metadata", () => { | |
| 100 | 100 | assert.deepEqual(unit("events").dependsOn, ["crates/contracts", "crates/kit"]); | |
| 101 | − | assert.deepEqual(unit("repos").dependsOn, ["crates/blobstore", "crates/contracts", "crates/kit", "crates/scan", "crates/secrets"]); | |
| 101 | + | assert.deepEqual(unit("repos").dependsOn, ["crates/blobstore", "crates/contracts", "crates/kit", "crates/rules", "crates/scan", "crates/secrets"]); | |
| 102 | + | assert.ok(unit("work").dependsOn.includes("crates/rules")); | |
| 102 | 103 | assert.ok(unit("actions").dependsOn.includes("crates/actions")); | |
| 103 | 104 | assert.ok(!unit("events").dependsOn.includes("crates/scan")); | |
| 104 | 105 | assert.deepEqual(unit("web").dependsOn, ["packages/contracts", "packages/theme"]); |
| 619 | 619 | Ok(rows.into_iter().map(SshKey::from).collect()) | |
| 620 | 620 | } | |
| 621 | 621 | ||
| 622 | + | /// The account (user id) that registered each key, by fingerprint | |
| 623 | + | /// (`SHA256:…`). At most 100; unknown keys are left out. | |
| 624 | + | async fn ssh_key_owners(&self, a: SshKeyOwnersArgs) -> Result<std::collections::HashMap<String, String>> { | |
| 625 | + | #[derive(serde::Deserialize)] | |
| 626 | + | struct Row { | |
| 627 | + | fingerprint: String, | |
| 628 | + | user_id: String, | |
| 629 | + | } | |
| 630 | + | let fingerprints: Vec<&String> = a.fingerprints.iter().take(100).collect(); | |
| 631 | + | if fingerprints.is_empty() { | |
| 632 | + | return Ok(std::collections::HashMap::new()); | |
| 633 | + | } | |
| 634 | + | let marks = vec!["?"; fingerprints.len()].join(", "); | |
| 635 | + | let binds: Vec<JsValue> = fingerprints.iter().map(|fingerprint| fingerprint.as_str().into()).collect(); | |
| 636 | + | Ok(self | |
| 637 | + | .db | |
| 638 | + | .prepare(format!("SELECT fingerprint, user_id FROM ssh_keys WHERE fingerprint IN ({marks})")) | |
| 639 | + | .bind(&binds)? | |
| 640 | + | .all() | |
| 641 | + | .await? | |
| 642 | + | .results::<Row>()? | |
| 643 | + | .into_iter() | |
| 644 | + | .map(|row| (row.fingerprint, row.user_id)) | |
| 645 | + | .collect()) | |
| 646 | + | } | |
| 647 | + | ||
| 622 | 648 | async fn add_ssh_key(&self, a: AddSshKeyArgs) -> Result<Outcome<SshKey>> { | |
| 623 | 649 | let Some(key) = crypto::parse_ssh_key(&a.public_key) else { | |
| 624 | 650 | return Ok(Outcome::fail( | |
| ⋯ | |||
| 818 | 844 | "directory" => reply(&identity.directory(args(body)?).await?), | |
| 819 | 845 | "profile_workspaces" => reply(&identity.profile_workspaces(args(body)?).await?), | |
| 820 | 846 | "list_ssh_keys" => reply(&identity.list_ssh_keys(args(body)?).await?), | |
| 847 | + | // Services only: who registered each key, for verifying commit | |
| 848 | + | // signatures (repos' signatures.rs). | |
| 849 | + | "ssh_key_owners" => reply(&identity.ssh_key_owners(args(body)?).await?), | |
| 821 | 850 | "add_ssh_key" => reply(&identity.add_ssh_key(args(body)?).await?), | |
| 822 | 851 | "remove_ssh_key" => reply(&identity.remove("ssh_keys", args(body)?).await?), | |
| 823 | 852 | "list_access_tokens" => reply(&identity.list_access_tokens(args(body)?).await?), | |
| 11 | 11 | [dependencies] | |
| 12 | 12 | g1t-contracts.workspace = true | |
| 13 | 13 | g1t-kit.workspace = true | |
| 14 | + | g1t-rules.workspace = true | |
| 14 | 15 | g1t-scan.workspace = true | |
| 15 | 16 | g1t-secrets.workspace = true | |
| 16 | 17 | g1t-blobstore.workspace = true | |
| ⋯ | |||
| 21 | 22 | similar = "2" | |
| 22 | 23 | miniz_oxide = "0.9" | |
| 23 | 24 | base64 = "0.22" | |
| 25 | + | ed25519-dalek = "3" | |
| 26 | + | sha2 = "0.10" | |
| 24 | 27 | ||
| 25 | 28 | # wasm-opt at -O1: about the same gzipped size as -O in a tenth of the | |
| 26 | 29 | # time (docs/DEPLOYING.md, "Build speed"). | |
| 541 | 541 | } else { | |
| 542 | 542 | write_pack(&objects) | |
| 543 | 543 | }; | |
| 544 | + | // A branch of the repository itself: the rules of that branch hold | |
| 545 | + | // for the merge pushed to it, as for any push (rules.rs). A catch-up | |
| 546 | + | // brings nothing the base branch does not have, so no files. | |
| 547 | + | if !from_fork { | |
| 548 | + | let change = g1t_rules::push::RefChange { | |
| 549 | + | git_ref: format!("refs/heads/{branch}"), | |
| 550 | + | old: Some(head.hash.clone()), | |
| 551 | + | new: Some(commit_id.clone()), | |
| 552 | + | fast_forward: Some(true), | |
| 553 | + | commits: vec![crate::rules::made_commit( | |
| 554 | + | &commit_id, | |
| 555 | + | &merge_message(&base_branch, &branch, a.number), | |
| 556 | + | &author.email, | |
| 557 | + | 2, | |
| 558 | + | Vec::new(), | |
| 559 | + | )], | |
| 560 | + | complete: true, | |
| 561 | + | }; | |
| 562 | + | if let crate::rules::Ruled::Refused { message, .. } = | |
| 563 | + | self.check_changes(&source, &a.actor, g1t_contracts::rules::Action::Push, vec![change]).await? | |
| 564 | + | { | |
| 565 | + | return Ok(Outcome::fail(FailureCode::Forbidden, message)); | |
| 566 | + | } | |
| 567 | + | } | |
| 544 | 568 | let source_access = source_git.access(Scope::Write).await?; | |
| 545 | 569 | // Only if the branch is still where it was: a push that landed | |
| 546 | 570 | // meanwhile is kept, and this is refused. |
| 125 | 125 | if let Some(refusal) = self.protect_file(&repo, &a.actor, &a.path, a.content.as_bytes(), &commit_id).await { | |
| 126 | 126 | return Ok(Outcome::fail(FailureCode::Forbidden, refusal)); | |
| 127 | 127 | } | |
| 128 | + | // The rules of the new branch, as for a push of this commit. | |
| 129 | + | let change = g1t_rules::push::RefChange { | |
| 130 | + | git_ref: format!("refs/heads/{}", a.branch), | |
| 131 | + | old: None, | |
| 132 | + | new: Some(commit_id.clone()), | |
| 133 | + | fast_forward: None, | |
| 134 | + | commits: vec![crate::rules::made_commit( | |
| 135 | + | &commit_id, | |
| 136 | + | message, | |
| 137 | + | &author.email, | |
| 138 | + | 1, | |
| 139 | + | vec![g1t_contracts::rules::FileChange { path: a.path.clone(), size: Some(blob.len() as u64), deleted: false }], | |
| 140 | + | )], | |
| 141 | + | complete: true, | |
| 142 | + | }; | |
| 143 | + | if let crate::rules::Ruled::Refused { message, .. } = | |
| 144 | + | self.check_changes(&repo, &a.actor, g1t_contracts::rules::Action::Commit, vec![change]).await? | |
| 145 | + | { | |
| 146 | + | return Ok(Outcome::fail(FailureCode::Forbidden, message)); | |
| 147 | + | } | |
| 128 | 148 | let mut objects: Vec<(ObjectKind, Vec<u8>)> = vec![(ObjectKind::Blob, blob)]; | |
| 129 | 149 | objects.extend(merged.objects.into_iter().map(|bytes| (ObjectKind::Tree, bytes))); | |
| 130 | 150 | objects.push((ObjectKind::Commit, commit)); |
| 314 | 314 | line | |
| 315 | 315 | } | |
| 316 | 316 | ||
| 317 | + | /// The branches and tags a push asks to change, as rules see them: the | |
| 318 | + | /// full ref, where it pointed (`None`: it is created) and where it will | |
| 319 | + | /// (`None`: it is deleted). | |
| 320 | + | pub(crate) fn ref_updates(body: &[u8]) -> Vec<(String, Option<String>, Option<String>)> { | |
| 321 | + | commands(body) | |
| 322 | + | .0 | |
| 323 | + | .into_iter() | |
| 324 | + | .filter(|command| command.name.starts_with(HEADS) || command.name.starts_with(TAGS)) | |
| 325 | + | .map(|Command { old, new, name }| (name, (old != ZERO_ID).then_some(old), (new != ZERO_ID).then_some(new))) | |
| 326 | + | .collect() | |
| 327 | + | } | |
| 328 | + | ||
| 329 | + | /// A report-status answer, as git expects it. | |
| 330 | + | pub(crate) fn report_response(report: Vec<u8>) -> Result<Response> { | |
| 331 | + | let headers = Headers::new(); | |
| 332 | + | headers.set("content-type", "application/x-git-receive-pack-result")?; | |
| 333 | + | headers.set("cache-control", "no-cache")?; | |
| 334 | + | Ok(Response::from_bytes(report)?.with_headers(headers)) | |
| 335 | + | } | |
| 336 | + | ||
| 317 | 337 | /// What git is told when a push would change a protected branch: every ref | |
| 318 | 338 | /// in it is declined, with the reason against the protected one, so that | |
| 319 | 339 | /// git prints it beside the branch. `None` if the push leaves the branch | |
| 320 | − | /// alone, or creates it in a repository that does not have it yet. | |
| 321 | − | fn refusal(body: &[u8], protected: &str) -> Option<Vec<u8>> { | |
| 340 | + | /// alone, or creates it in a repository that does not have it yet. Only | |
| 341 | + | /// where rulesets cannot be read (an installation without the work | |
| 342 | + | /// service); rulesets decide everywhere else (rules.rs). | |
| 343 | + | pub(crate) fn refusal(body: &[u8], protected: &str) -> Option<Vec<u8>> { | |
| 322 | 344 | let (commands, capabilities) = commands(body); | |
| 323 | 345 | let reference = format!("{HEADS}{protected}"); | |
| 324 | 346 | if !commands | |
| ⋯ | |||
| 438 | 460 | /// What became of a git request. | |
| 439 | 461 | pub enum Push { | |
| 440 | 462 | Forwarded(Forwarded), | |
| 441 | − | /// A push to a protected branch, answered here without reaching the store. | |
| 463 | + | /// A push the rules of its branches or tags refuse (rules.rs), answered | |
| 464 | + | /// here without reaching the store. | |
| 442 | 465 | Refused(Response), | |
| 443 | 466 | /// A push that adds a secret nobody allowed, answered the same way. | |
| 444 | 467 | Blocked(Response), | |
| ⋯ | |||
| 791 | 814 | } | |
| 792 | 815 | ||
| 793 | 816 | /// Sends the request on to the git store and returns its response as is, | |
| 794 | − | /// unless it is a push that would change the `protected` branch, one the | |
| 817 | + | /// unless it is a push the rules refuse (`rules`, rules.rs), one the | |
| 795 | 818 | /// store could not hold (`limits`, pack_limits.rs), or one that `scan` | |
| 796 | 819 | /// (push protection) answers itself. A fetch's ref listing has its `HEAD` | |
| 797 | 820 | /// pointed at `default_branch` (see [`with_head`]). A POST's body is | |
| ⋯ | |||
| 809 | 832 | read: Option<Vec<u8>>, | |
| 810 | 833 | git: &GitRequest, | |
| 811 | 834 | access: &GitAccess, | |
| 812 | − | protected: Option<&str>, | |
| 835 | + | rules: impl AsyncFnOnce(&[u8], bool) -> Result<Option<Response>>, | |
| 813 | 836 | default_branch: Option<&str>, | |
| 814 | 837 | limits: PushLimits, | |
| 815 | 838 | scan: impl AsyncFnOnce(&[u8]) -> Result<Option<Response>>, | |
| ⋯ | |||
| 828 | 851 | let namespace = crate::store::health_namespace(&access.remote); | |
| 829 | 852 | ||
| 830 | 853 | if method == Method::Post && git.endpoint == "git-receive-pack" { | |
| 831 | − | return push(request, &url, headers, protected, limits, scan, &namespace).await; | |
| 854 | + | return push(request, &url, headers, rules, limits, scan, &namespace).await; | |
| 832 | 855 | } | |
| 833 | 856 | ||
| 834 | 857 | // A read: the ref advertisement, `ls-refs`, or a fetch of objects. | |
| ⋯ | |||
| 915 | 938 | mut request: Request, | |
| 916 | 939 | url: &str, | |
| 917 | 940 | headers: Headers, | |
| 918 | − | protected: Option<&str>, | |
| 941 | + | rules: impl AsyncFnOnce(&[u8], bool) -> Result<Option<Response>>, | |
| 919 | 942 | limits: PushLimits, | |
| 920 | 943 | scan: impl AsyncFnOnce(&[u8]) -> Result<Option<Response>>, | |
| 921 | 944 | namespace: &str, | |
| ⋯ | |||
| 940 | 963 | } | |
| 941 | 964 | } | |
| 942 | 965 | } | |
| 943 | − | let report_headers = || -> Result<Headers> { | |
| 944 | − | let headers = Headers::new(); | |
| 945 | − | headers.set("content-type", "application/x-git-receive-pack-result")?; | |
| 946 | − | headers.set("cache-control", "no-cache")?; | |
| 947 | − | Ok(headers) | |
| 948 | − | }; | |
| 949 | − | if let Some(report) = protected.and_then(|branch| refusal(&head, branch)) { | |
| 966 | + | // The rules of the branches and tags it changes, first: what they | |
| 967 | + | // refuse is refused whatever else is wrong with it. | |
| 968 | + | if let Some(response) = rules(&head, ended).await? { | |
| 950 | 969 | if !ended { | |
| 951 | 970 | drain(&mut stream).await?; | |
| 952 | 971 | } | |
| 953 | − | return Ok(Push::Refused(Response::from_bytes(report)?.with_headers(report_headers()?))); | |
| 972 | + | return Ok(Push::Refused(response)); | |
| 954 | 973 | } | |
| 955 | 974 | if !ended && limits.large == LargePushes::Refuse && violation.is_none() { | |
| 956 | 975 | let size = head.len() as u64 + drain(&mut stream).await?; | |
| 30 | 30 | mod refs_cache; | |
| 31 | 31 | mod registry; | |
| 32 | 32 | mod resilience; | |
| 33 | + | mod rule_facts; | |
| 34 | + | mod rules; | |
| 33 | 35 | mod run_access; | |
| 34 | 36 | mod secret_scan; | |
| 35 | 37 | mod shards; | |
| 36 | 38 | mod shared; | |
| 39 | + | mod signatures; | |
| 37 | 40 | mod store; | |
| 38 | 41 | mod transfer; | |
| 39 | 42 | ||
| ⋯ | |||
| 185 | 188 | security: Option<Fetcher>, | |
| 186 | 189 | /// Asked whether a workspace is on a plan, for its private storage. | |
| 187 | 190 | billing: Option<Fetcher>, | |
| 191 | + | /// Says which rulesets hold for a change to a branch or tag, and keeps | |
| 192 | + | /// how they judged it (rules.rs). `None` where it is not deployed: the | |
| 193 | + | /// old protection flag then holds on push. | |
| 194 | + | work: Option<Fetcher>, | |
| 188 | 195 | /// Told when a repository moves, for the tokens of agents at work on it. | |
| 189 | 196 | identity: Option<Fetcher>, | |
| 190 | 197 | /// What a free workspace's private repositories may hold. | |
| ⋯ | |||
| 354 | 361 | website, | |
| 355 | 362 | ..repo | |
| 356 | 363 | }; | |
| 364 | + | // Whether the default branch takes only pull requests is now its | |
| 365 | + | // branch protection ruleset's to say (work's rulesets.rs). | |
| 366 | + | if let (Some(protected), Some(work)) = (a.protected, &self.work) { | |
| 367 | + | #[derive(Serialize)] | |
| 368 | + | struct RequirePullRequest<'a> { | |
| 369 | + | repo: &'a Repo, | |
| 370 | + | protected: bool, | |
| 371 | + | actor: &'a User, | |
| 372 | + | } | |
| 373 | + | let set: Result<Outcome<bool>> = | |
| 374 | + | g1t_kit::call(work, "set_requires_pull_request", &RequirePullRequest { repo: &updated, protected, actor: &a.actor }).await; | |
| 375 | + | match set { | |
| 376 | + | Ok(Outcome::Ok(_)) => {} | |
| 377 | + | Ok(Outcome::Fail(failure)) => return Ok(Outcome::Fail(failure)), | |
| 378 | + | Err(error) => return Err(error), | |
| 379 | + | } | |
| 380 | + | } | |
| 357 | 381 | if let Some(private) = wants_private { | |
| 358 | 382 | return self.change_visibility(updated, private, &a.actor, a.surface).await; | |
| 359 | 383 | } | |
| ⋯ | |||
| 1617 | 1641 | // again before git uses it (forks.rs). | |
| 1618 | 1642 | self.live(&repo).await?; | |
| 1619 | 1643 | timing.mark("access"); | |
| 1620 | − | // A protected default branch takes changes only from a merged pull | |
| 1621 | − | // request, which lands without going through here. | |
| 1622 | − | let protected = (repo.protected && repo.fork_of.is_none()).then(|| repo.default_branch.clone()); | |
| 1623 | 1644 | // Clones check out the default branch g1t keeps, which can have | |
| 1624 | 1645 | // changed since the store made the repository. | |
| 1625 | 1646 | let default_branch = repo.fork_of.is_none().then(|| repo.default_branch.clone()); | |
| ⋯ | |||
| 1738 | 1759 | let again = if get { Some(request.clone()?) } else { None }; | |
| 1739 | 1760 | // Push protection: a push that adds a secret is refused. See secret_scan.rs. | |
| 1740 | 1761 | let scan = async |body: &[u8]| self.protect(&repo, viewer.as_ref(), body).await; | |
| 1762 | + | // Rulesets: what the rules of the branches and tags it changes | |
| 1763 | + | // refuse is declined, saying which rule and why (rules.rs). | |
| 1764 | + | let rules = async |head: &[u8], whole: bool| self.check_push(&repo, viewer.as_ref(), head, whole).await; | |
| 1741 | 1765 | // What a push may bring (pack_limits.rs): the repository's size is | |
| 1742 | 1766 | // its own and its pull requests' working copies'. | |
| 1743 | 1767 | let limits = if write && !get { | |
| ⋯ | |||
| 1755 | 1779 | body, | |
| 1756 | 1780 | git, | |
| 1757 | 1781 | &access, | |
| 1758 | − | protected.as_deref(), | |
| 1782 | + | rules, | |
| 1759 | 1783 | default_branch.as_deref(), | |
| 1760 | 1784 | limits, | |
| 1761 | 1785 | scan, | |
| ⋯ | |||
| 1775 | 1799 | None, | |
| 1776 | 1800 | git, | |
| 1777 | 1801 | &access, | |
| 1778 | − | protected.as_deref(), | |
| 1802 | + | async |_: &[u8], _: bool| Ok(None), | |
| 1779 | 1803 | default_branch.as_deref(), | |
| 1780 | 1804 | git_http::PushLimits::default(), | |
| 1781 | 1805 | nothing, | |
| ⋯ | |||
| 1787 | 1811 | match outcome { | |
| 1788 | 1812 | git_http::Push::Forwarded(forwarded) => forwarded, | |
| 1789 | 1813 | git_http::Push::Refused(response) => { | |
| 1790 | − | after.ended(403, Some("The push would change a protected branch.".to_owned())); | |
| 1814 | + | after.ended(403, Some("The push was declined by rules.".to_owned())); | |
| 1791 | 1815 | after.spawn(env, ctx); | |
| 1792 | 1816 | return Ok(response); | |
| 1793 | 1817 | } | |
| ⋯ | |||
| 2064 | 2088 | security: env.service("SECURITY").ok(), | |
| 2065 | 2089 | billing: env.service("BILLING").ok(), | |
| 2066 | 2090 | identity: env.service("IDENTITY").ok(), | |
| 2091 | + | work: env.service("WORK").ok(), | |
| 2067 | 2092 | free_private_bytes: git_ops::free_private_bytes(env), | |
| 2068 | 2093 | fork_days: forks::retention_days(env), | |
| 2069 | 2094 | repo_limit: env | |
| ⋯ | |||
| 2238 | 2263 | "delete_branch" => reply(&repos.delete_branch(args(body)?).await?), | |
| 2239 | 2264 | "commit_file" => reply(&repos.commit_file(args(body)?).await?), | |
| 2240 | 2265 | "compare" => reply(&repos.compare(args(body)?).await?), | |
| 2266 | + | // Services only: a pull request's commits, as rules look at them (rules.rs). | |
| 2267 | + | "inspect_commits" => reply(&repos.inspect_commits(args(body)?).await?), | |
| 2241 | 2268 | "scan_history" => reply(&repos.scan_history(args(body)?).await?), | |
| 2242 | 2269 | "find_lockfiles" => reply(&repos.find_lockfiles(args(body)?).await?), | |
| 2243 | 2270 | "match_pattern" => reply(&repos.match_pattern(args(body)?).await?), | |
| 1356 | 1356 | let Some(head) = branches.iter().find(|b| b.name == from).map(|b| b.hash.clone()) else { | |
| 1357 | 1357 | return Ok(not_found()); | |
| 1358 | 1358 | }; | |
| 1359 | + | // A rename deletes one name and creates another: the rules of both | |
| 1360 | + | // hold (rules.rs). | |
| 1361 | + | let renamed = vec![ | |
| 1362 | + | g1t_rules::push::RefChange { | |
| 1363 | + | git_ref: format!("refs/heads/{from}"), | |
| 1364 | + | old: Some(head.clone()), | |
| 1365 | + | new: None, | |
| 1366 | + | complete: true, | |
| 1367 | + | ..Default::default() | |
| 1368 | + | }, | |
| 1369 | + | g1t_rules::push::RefChange { | |
| 1370 | + | git_ref: format!("refs/heads/{to}"), | |
| 1371 | + | old: None, | |
| 1372 | + | new: Some(head.clone()), | |
| 1373 | + | complete: true, | |
| 1374 | + | ..Default::default() | |
| 1375 | + | }, | |
| 1376 | + | ]; | |
| 1377 | + | if let crate::rules::Ruled::Refused { message, .. } = | |
| 1378 | + | self.check_changes(&repo, &a.actor, g1t_contracts::rules::Action::RenameRef, renamed).await? | |
| 1379 | + | { | |
| 1380 | + | return Ok(Outcome::fail(FailureCode::Forbidden, message)); | |
| 1381 | + | } | |
| 1359 | 1382 | let access = git.access(Scope::Write).await?; | |
| 1360 | 1383 | let made = land::push_pack(&access, &to, None, &head, EMPTY_PACK.to_vec()).await?; | |
| 1361 | 1384 | self.refs_moved(&repo.id).await; |
| 1 | + | //! What rules about commits look at, read from a pack: each commit's | |
| 2 | + | //! message, addresses, parents and signature, and the files it adds, | |
| 3 | + | //! changes or deletes with their sizes. A push's pack is read before it is | |
| 4 | + | //! stored; a pull request's commits are fetched as a pack from its source | |
| 5 | + | //! (`inspect_commits`), so both are read by the same code. | |
| 6 | + | ||
| 7 | + | use std::cell::Cell; | |
| 8 | + | use std::collections::{HashMap, HashSet, VecDeque}; | |
| 9 | + | ||
| 10 | + | use g1t_contracts::rules::{CommitFacts, FileChange, Signature}; | |
| 11 | + | use g1t_scan::pack::{ObjectKind, Pack}; | |
| 12 | + | use worker::Result; | |
| 13 | + | ||
| 14 | + | use crate::secret_scan::Objects; | |
| 15 | + | use crate::store::GitRepo; | |
| 16 | + | ||
| 17 | + | /// Who registered each signing key (fingerprint to user id), and who | |
| 18 | + | /// verified each address (to user id and username). | |
| 19 | + | pub type Owners = (HashMap<String, String>, HashMap<String, (String, String)>); | |
| 20 | + | ||
| 21 | + | /// The most commits read for one ref. | |
| 22 | + | pub const MAX_COMMITS: usize = 300; | |
| 23 | + | /// The most files listed for one commit; more is not complete. | |
| 24 | + | pub const MAX_FILES: usize = 1000; | |
| 25 | + | /// The most of a message kept. | |
| 26 | + | const MAX_MESSAGE: usize = 4096; | |
| 27 | + | ||
| 28 | + | /// A raw commit's headers and message. | |
| 29 | + | #[derive(Debug, Default, PartialEq, Eq)] | |
| 30 | + | pub struct CommitText { | |
| 31 | + | pub tree: String, | |
| 32 | + | pub parents: Vec<String>, | |
| 33 | + | pub author_email: Option<String>, | |
| 34 | + | pub committer_email: Option<String>, | |
| 35 | + | pub message: String, | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | fn email(value: &str) -> Option<String> { | |
| 39 | + | let start = value.rfind('<')?; | |
| 40 | + | let end = start + value[start..].find('>')?; | |
| 41 | + | Some(value[start + 1..end].trim().to_owned()) | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | /// Reads a raw commit object. | |
| 45 | + | pub fn read_commit(data: &[u8]) -> CommitText { | |
| 46 | + | let text = String::from_utf8_lossy(data); | |
| 47 | + | let (headers, message) = text.split_once("\n\n").unwrap_or((&text, "")); | |
| 48 | + | let mut commit = CommitText::default(); | |
| 49 | + | for line in headers.split('\n') { | |
| 50 | + | if let Some(tree) = line.strip_prefix("tree ") { | |
| 51 | + | commit.tree = tree.trim().to_owned(); | |
| 52 | + | } else if let Some(parent) = line.strip_prefix("parent ") { | |
| 53 | + | commit.parents.push(parent.trim().to_owned()); | |
| 54 | + | } else if let Some(author) = line.strip_prefix("author ") { | |
| 55 | + | commit.author_email = email(author); | |
| 56 | + | } else if let Some(committer) = line.strip_prefix("committer ") { | |
| 57 | + | commit.committer_email = email(committer); | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | let mut end = message.len().min(MAX_MESSAGE); | |
| 61 | + | while !message.is_char_boundary(end) { | |
| 62 | + | end -= 1; | |
| 63 | + | } | |
| 64 | + | commit.message = message[..end].to_owned(); | |
| 65 | + | commit | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | /// The commits of the pack reachable from `tip` without leaving it, | |
| 69 | + | /// newest first: what a push adds to a ref. `None` past `limit`. | |
| 70 | + | pub fn added(pack: &Pack, tip: &str, limit: usize) -> Option<Vec<String>> { | |
| 71 | + | let mut seen = HashSet::new(); | |
| 72 | + | let mut queue = VecDeque::from([tip.to_owned()]); | |
| 73 | + | let mut out = Vec::new(); | |
| 74 | + | while let Some(id) = queue.pop_front() { | |
| 75 | + | if !seen.insert(id.clone()) { | |
| 76 | + | continue; | |
| 77 | + | } | |
| 78 | + | let Some((ObjectKind::Commit, data)) = pack.get(&id) else { | |
| 79 | + | continue; | |
| 80 | + | }; | |
| 81 | + | out.push(id); | |
| 82 | + | if out.len() > limit { | |
| 83 | + | return None; | |
| 84 | + | } | |
| 85 | + | queue.extend(read_commit(data).parents); | |
| 86 | + | } | |
| 87 | + | Some(out) | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | /// The files that differ between two trees, deletions included, with the | |
| 91 | + | /// size of each new blob the pack holds. Whether the list is complete. | |
| 92 | + | async fn changed<R: GitRepo>(objects: &Objects<'_, R>, old_root: Option<String>, new_root: Option<String>) -> Result<(Vec<FileChange>, bool)> { | |
| 93 | + | let mut files = Vec::new(); | |
| 94 | + | let mut level: Vec<(String, Option<String>, Option<String>)> = vec![(String::new(), old_root, new_root)]; | |
| 95 | + | while !level.is_empty() { | |
| 96 | + | let mut next = Vec::new(); | |
| 97 | + | for (prefix, old, new) in level { | |
| 98 | + | let old_items = match &old { | |
| 99 | + | Some(id) => objects.tree(id).await?, | |
| 100 | + | None => Vec::new(), | |
| 101 | + | }; | |
| 102 | + | let new_items = match &new { | |
| 103 | + | Some(id) => objects.tree(id).await?, | |
| 104 | + | None => Vec::new(), | |
| 105 | + | }; | |
| 106 | + | for item in &new_items { | |
| 107 | + | let before = old_items.iter().find(|entry| entry.name == item.name); | |
| 108 | + | if before.is_some_and(|before| before.id == item.id && before.mode == item.mode) { | |
| 109 | + | continue; | |
| 110 | + | } | |
| 111 | + | let path = format!("{prefix}{}", item.name); | |
| 112 | + | if item.is_tree() { | |
| 113 | + | next.push((format!("{path}/"), before.filter(|b| b.is_tree()).map(|b| b.id.clone()), Some(item.id.clone()))); | |
| 114 | + | // A file replaced by a directory is deleted. | |
| 115 | + | if before.is_some_and(|b| !b.is_tree()) { | |
| 116 | + | files.push(FileChange { path: path.clone(), size: None, deleted: true }); | |
| 117 | + | } | |
| 118 | + | } else { | |
| 119 | + | let size = match objects.pack.get(&item.id) { | |
| 120 | + | Some((ObjectKind::Blob, data)) => Some(data.len() as u64), | |
| 121 | + | _ => None, | |
| 122 | + | }; | |
| 123 | + | files.push(FileChange { path, size, deleted: false }); | |
| 124 | + | if let Some(before) = before.filter(|b| b.is_tree()) { | |
| 125 | + | next.push((format!("{prefix}{}/", item.name), Some(before.id.clone()), None)); | |
| 126 | + | } | |
| 127 | + | } | |
| 128 | + | } | |
| 129 | + | for item in &old_items { | |
| 130 | + | if new_items.iter().any(|entry| entry.name == item.name) { | |
| 131 | + | continue; | |
| 132 | + | } | |
| 133 | + | let path = format!("{prefix}{}", item.name); | |
| 134 | + | if item.is_tree() { | |
| 135 | + | next.push((format!("{path}/"), Some(item.id.clone()), None)); | |
| 136 | + | } else { | |
| 137 | + | files.push(FileChange { path, size: None, deleted: true }); | |
| 138 | + | } | |
| 139 | + | } | |
| 140 | + | if files.len() > MAX_FILES { | |
| 141 | + | files.truncate(MAX_FILES); | |
| 142 | + | return Ok((files, false)); | |
| 143 | + | } | |
| 144 | + | } | |
| 145 | + | level = next; | |
| 146 | + | } | |
| 147 | + | Ok((files, true)) | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | /// One commit of the pack, read as rules look at it. `signature` is what | |
| 151 | + | /// was made of its signature, when one was asked for. | |
| 152 | + | pub async fn facts<R: GitRepo>(objects: &Objects<'_, R>, id: &str, signature: Option<Signature>) -> Result<Option<CommitFacts>> { | |
| 153 | + | let Some((ObjectKind::Commit, data)) = objects.pack.get(id) else { | |
| 154 | + | return Ok(None); | |
| 155 | + | }; | |
| 156 | + | let commit = read_commit(data); | |
| 157 | + | let old_tree = match commit.parents.first() { | |
| 158 | + | Some(parent) => objects.commit_tree(parent).await?, | |
| 159 | + | None => None, | |
| 160 | + | }; | |
| 161 | + | let (files, files_complete) = changed(objects, old_tree, Some(commit.tree.clone())).await?; | |
| 162 | + | Ok(Some(CommitFacts { | |
| 163 | + | sha: id.to_owned(), | |
| 164 | + | message: commit.message, | |
| 165 | + | author_email: commit.author_email, | |
| 166 | + | committer_email: commit.committer_email, | |
| 167 | + | parents: commit.parents.len() as u32, | |
| 168 | + | signature: signature.unwrap_or_default(), | |
| 169 | + | files, | |
| 170 | + | files_complete, | |
| 171 | + | })) | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | /// Whether `old` is in the history of `new`: a fast-forward. Walks the | |
| 175 | + | /// pack's commits, then the repository's history from where it leaves it. | |
| 176 | + | pub async fn contains<R: GitRepo>(pack: &Pack, repo: &R, new: &str, old: &str, depth: u32) -> Result<bool> { | |
| 177 | + | if new == old { | |
| 178 | + | return Ok(true); | |
| 179 | + | } | |
| 180 | + | let mut seen = HashSet::new(); | |
| 181 | + | let mut queue = VecDeque::from([new.to_owned()]); | |
| 182 | + | let mut boundary = Vec::new(); | |
| 183 | + | while let Some(id) = queue.pop_front() { | |
| 184 | + | if id == old { | |
| 185 | + | return Ok(true); | |
| 186 | + | } | |
| 187 | + | if !seen.insert(id.clone()) || seen.len() > 5000 { | |
| 188 | + | continue; | |
| 189 | + | } | |
| 190 | + | match pack.get(&id) { | |
| 191 | + | Some((ObjectKind::Commit, data)) => queue.extend(read_commit(data).parents), | |
| 192 | + | _ => boundary.push(id), | |
| 193 | + | } | |
| 194 | + | } | |
| 195 | + | for start in boundary.iter().take(20) { | |
| 196 | + | let history = repo.log(start, depth).await?; | |
| 197 | + | if history.iter().any(|commit| commit.hash == old) { | |
| 198 | + | return Ok(true); | |
| 199 | + | } | |
| 200 | + | // Merges: the history is first-parent only, so look along the | |
| 201 | + | // second parents it names too, a step at a time. | |
| 202 | + | for commit in history.iter().filter(|commit| commit.parents.len() > 1).take(10) { | |
| 203 | + | for parent in commit.parents.iter().skip(1) { | |
| 204 | + | if parent == old || repo.log(parent, depth).await?.iter().any(|commit| commit.hash == old) { | |
| 205 | + | return Ok(true); | |
| 206 | + | } | |
| 207 | + | } | |
| 208 | + | } | |
| 209 | + | } | |
| 210 | + | Ok(false) | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | /// The signature fingerprints and committer addresses of commits, for | |
| 214 | + | /// looking up who owns them. | |
| 215 | + | pub fn signing_facts(pack: &Pack, ids: &[String]) -> (Vec<String>, Vec<String>) { | |
| 216 | + | let mut fingerprints = Vec::new(); | |
| 217 | + | let mut emails = Vec::new(); | |
| 218 | + | for id in ids { | |
| 219 | + | let Some((ObjectKind::Commit, data)) = pack.get(id) else { continue }; | |
| 220 | + | if let Some(fingerprint) = crate::signatures::fingerprint(data) | |
| 221 | + | && !fingerprints.contains(&fingerprint) | |
| 222 | + | { | |
| 223 | + | fingerprints.push(fingerprint); | |
| 224 | + | if let Some(email) = read_commit(data).committer_email.map(|email| email.to_lowercase()) | |
| 225 | + | && !emails.contains(&email) | |
| 226 | + | { | |
| 227 | + | emails.push(email); | |
| 228 | + | } | |
| 229 | + | } | |
| 230 | + | } | |
| 231 | + | (fingerprints, emails) | |
| 232 | + | } | |
| 233 | + | ||
| 234 | + | /// Every commit of `ids` read, with its signature decided against who | |
| 235 | + | /// owns the keys and addresses (`owners`, when signatures matter). | |
| 236 | + | pub async fn read_all<R: GitRepo>( | |
| 237 | + | pack: &Pack, | |
| 238 | + | repo: &R, | |
| 239 | + | ids: &[String], | |
| 240 | + | owners: Option<&Owners>, | |
| 241 | + | ) -> Result<Vec<CommitFacts>> { | |
| 242 | + | let objects = Objects { pack, repo, reads: Cell::new(0) }; | |
| 243 | + | let mut out = Vec::new(); | |
| 244 | + | for id in ids { | |
| 245 | + | let signature = owners.and_then(|(keys, emails)| { | |
| 246 | + | let (_, data) = pack.get(id)?; | |
| 247 | + | let committer = read_commit(data).committer_email; | |
| 248 | + | Some(crate::signatures::decide(data, committer.as_deref(), keys, emails)) | |
| 249 | + | }); | |
| 250 | + | if let Some(facts) = facts(&objects, id, signature).await? { | |
| 251 | + | out.push(facts); | |
| 252 | + | } | |
| 253 | + | } | |
| 254 | + | Ok(out) | |
| 255 | + | } | |
| 256 | + | ||
| 257 | + | #[cfg(test)] | |
| 258 | + | mod tests { | |
| 259 | + | use super::*; | |
| 260 | + | ||
| 261 | + | #[test] | |
| 262 | + | fn a_commit_reads_its_parents_addresses_and_message() { | |
| 263 | + | let raw = b"tree aaaa\nparent bbbb\nparent cccc\nauthor Ada Lovelace <ada@acme.com> 1 +0000\ncommitter G <noreply@g1t.sh> 1 +0000\ngpgsig -----BEGIN SSH SIGNATURE-----\n abc\n -----END SSH SIGNATURE-----\n\nfeat: rules\n\nWith a body.\n"; | |
| 264 | + | let commit = read_commit(raw); | |
| 265 | + | assert_eq!(commit.tree, "aaaa"); | |
| 266 | + | assert_eq!(commit.parents, vec!["bbbb", "cccc"]); | |
| 267 | + | assert_eq!(commit.author_email.as_deref(), Some("ada@acme.com")); | |
| 268 | + | assert_eq!(commit.committer_email.as_deref(), Some("noreply@g1t.sh")); | |
| 269 | + | assert_eq!(commit.message, "feat: rules\n\nWith a body.\n"); | |
| 270 | + | } | |
| 271 | + | ||
| 272 | + | #[test] | |
| 273 | + | fn a_long_message_is_cut_on_a_character() { | |
| 274 | + | let message = "é".repeat(5000); | |
| 275 | + | let raw = format!("tree a\n\n{message}"); | |
| 276 | + | assert!(read_commit(raw.as_bytes()).message.len() <= MAX_MESSAGE); | |
| 277 | + | } | |
| 278 | + | ||
| 279 | + | #[test] | |
| 280 | + | fn the_commits_a_push_adds_are_those_its_pack_holds() { | |
| 281 | + | use g1t_scan::pack::write_pack; | |
| 282 | + | let first = b"tree t\nauthor A <a@x> 1 +0000\ncommitter A <a@x> 1 +0000\n\none\n".to_vec(); | |
| 283 | + | let first_id = g1t_scan::pack::object_id(ObjectKind::Commit, &first); | |
| 284 | + | let second = format!("tree t\nparent {first_id}\nparent {}\nauthor A <a@x> 1 +0000\ncommitter A <a@x> 1 +0000\n\ntwo\n", "f".repeat(40)).into_bytes(); | |
| 285 | + | let second_id = g1t_scan::pack::object_id(ObjectKind::Commit, &second); | |
| 286 | + | let pack = Pack::parse(&write_pack(&[(ObjectKind::Commit, first), (ObjectKind::Commit, second)])).unwrap(); | |
| 287 | + | assert_eq!(added(&pack, &second_id, 10), Some(vec![second_id.clone(), first_id.clone()])); | |
| 288 | + | assert_eq!(added(&pack, &second_id, 1), None, "past the limit"); | |
| 289 | + | assert_eq!(added(&pack, &"0".repeat(40), 10), Some(Vec::new()), "a tip the pack does not hold adds nothing"); | |
| 290 | + | } | |
| 291 | + | } |
| 1 | + | //! Rulesets, enforced here: on every push, and on every other change to a | |
| 2 | + | //! branch or tag made through g1t (renaming a branch, a commit made on the | |
| 3 | + | //! site, a pull request brought up to date). The work service keeps the | |
| 4 | + | //! rulesets and says which hold (`ref_rules`); `g1t_rules` judges; every | |
| 5 | + | //! judgement is sent back to be recorded (`record_evaluations`). Merging a | |
| 6 | + | //! pull request is judged by the work service before it asks `land`. | |
| 7 | + | //! | |
| 8 | + | //! A pull request's working copy (a fork) has no rules of its own: what it | |
| 9 | + | //! brings is judged when it merges. | |
| 10 | + | ||
| 11 | + | use std::collections::HashMap; | |
| 12 | + | ||
| 13 | + | use g1t_contracts::accounts::{EmailOwner, EmailOwnersArgs}; | |
| 14 | + | use g1t_contracts::repos::Repo; | |
| 15 | + | use g1t_contracts::rules::{ | |
| 16 | + | Action, Applicable, CommitFacts, Enforcement, FileChange, InspectCommitsArgs, InspectedCommits, | |
| 17 | + | RecordEvaluationsArgs, RefRules, RefRulesArgs, Rule, Target, | |
| 18 | + | }; | |
| 19 | + | use g1t_contracts::{FailureCode, Outcome, User}; | |
| 20 | + | use g1t_rules::push::{RefChange, judge}; | |
| 21 | + | use g1t_rules::{ActorFacts, Judged, Who, content, outcome, report}; | |
| 22 | + | use g1t_scan::pack::{Pack, pack_start}; | |
| 23 | + | use worker::{Response, Result}; | |
| 24 | + | ||
| 25 | + | use crate::registry::store_key; | |
| 26 | + | use crate::rule_facts::{self, MAX_COMMITS}; | |
| 27 | + | use crate::store::{GitRepo, GitStore}; | |
| 28 | + | use crate::{MAX_ANCESTRY, Repos}; | |
| 29 | + | ||
| 30 | + | /// Where people read the rules of a branch. | |
| 31 | + | const SITE: &str = "https://g1t.sh"; | |
| 32 | + | ||
| 33 | + | /// What the rules said about a change. | |
| 34 | + | pub(crate) enum Ruled { | |
| 35 | + | /// No ruleset holds, or none refuses it. | |
| 36 | + | Allowed, | |
| 37 | + | /// Refused: why, in a sentence. | |
| 38 | + | Refused { message: String }, | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | /// `ssh_key_owners` on identity: the account that registered each key. | |
| 42 | + | #[derive(serde::Serialize)] | |
| 43 | + | struct KeyOwnersArgs<'a> { | |
| 44 | + | fingerprints: &'a [String], | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | fn who(actor: Option<&User>, repo: &Repo) -> ActorFacts { | |
| 48 | + | match actor { | |
| 49 | + | Some(actor) => ActorFacts::of(actor, repo), | |
| 50 | + | None => ActorFacts { username: "anonymous".to_owned(), ..ActorFacts::default() }, | |
| 51 | + | } | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | /// Whether any ruleset that is evaluated (active, or evaluate) has a rule | |
| 55 | + | /// about commits that holds for this actor. | |
| 56 | + | fn needs_commits(rulesets: &[Applicable], kind: Who) -> bool { | |
| 57 | + | rulesets.iter().filter(|ruleset| ruleset.enforcement != Enforcement::Disabled).any(|ruleset| { | |
| 58 | + | ruleset | |
| 59 | + | .rules | |
| 60 | + | .iter() | |
| 61 | + | .any(|entry| entry.applies_to.covers(kind.is_agent()) && content::about_content(&entry.rule)) | |
| 62 | + | }) | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | fn needs_ancestry(rulesets: &[Applicable]) -> bool { | |
| 66 | + | rulesets | |
| 67 | + | .iter() | |
| 68 | + | .any(|ruleset| ruleset.rules.iter().any(|entry| matches!(entry.rule, Rule::NonFastForward(_)))) | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | /// Where the rules of a ref are shown. | |
| 72 | + | pub(crate) fn rules_url(repo: &Repo, git_ref: &str) -> String { | |
| 73 | + | let (target, name) = Target::of_ref(git_ref).unwrap_or((Target::Branch, git_ref)); | |
| 74 | + | let key = if target == Target::Tag { "tag" } else { "branch" }; | |
| 75 | + | format!("{SITE}/{}/{}/settings/rules?{key}={name}", repo.namespace, repo.name) | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | impl<S: GitStore> Repos<S> { | |
| 79 | + | /// The rulesets that hold for `refs`, from the work service. `None` | |
| 80 | + | /// when this installation runs without it. | |
| 81 | + | async fn ref_rules(&self, repo: &Repo, actor: Option<&User>, refs: Vec<String>) -> Result<Option<RefRules>> { | |
| 82 | + | let Some(work) = &self.work else { return Ok(None) }; | |
| 83 | + | let found: Outcome<RefRules> = | |
| 84 | + | g1t_kit::call(work, "ref_rules", &RefRulesArgs { repo: repo.clone(), actor: actor.cloned(), refs }).await?; | |
| 85 | + | match found { | |
| 86 | + | Outcome::Ok(rules) => Ok(Some(rules)), | |
| 87 | + | Outcome::Fail(failure) => Err(worker::Error::RustError(failure.message)), | |
| 88 | + | } | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | /// Sends judgements to be recorded; a failure is logged. | |
| 92 | + | async fn record_judged(&self, repo: &Repo, judged: &[Judged], action: Action, actor: &ActorFacts, sha: Option<&str>) { | |
| 93 | + | let Some(work) = &self.work else { return }; | |
| 94 | + | if judged.is_empty() { | |
| 95 | + | return; | |
| 96 | + | } | |
| 97 | + | let evaluations = g1t_rules::evaluations(judged, &repo.id, &repo.namespace, action, actor, None, sha); | |
| 98 | + | let recorded: Result<u32> = g1t_kit::call(work, "record_evaluations", &RecordEvaluationsArgs { evaluations }).await; | |
| 99 | + | if let Err(error) = recorded { | |
| 100 | + | worker::console_error!("rule evaluations not recorded: {error}"); | |
| 101 | + | } | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | /// Who owns the keys commits were signed with, and the addresses | |
| 105 | + | /// they were committed as. | |
| 106 | + | async fn signing_owners( | |
| 107 | + | &self, | |
| 108 | + | fingerprints: &[String], | |
| 109 | + | emails: &[String], | |
| 110 | + | ) -> (HashMap<String, String>, HashMap<String, (String, String)>) { | |
| 111 | + | let Some(identity) = &self.identity else { return (HashMap::new(), HashMap::new()) }; | |
| 112 | + | if fingerprints.is_empty() { | |
| 113 | + | return (HashMap::new(), HashMap::new()); | |
| 114 | + | } | |
| 115 | + | let (keys, owners) = futures_util::future::join( | |
| 116 | + | g1t_kit::call::<_, HashMap<String, String>>(identity, "ssh_key_owners", &KeyOwnersArgs { fingerprints }), | |
| 117 | + | g1t_kit::call::<_, HashMap<String, EmailOwner>>(identity, "email_owners", &EmailOwnersArgs { emails: emails.to_vec() }), | |
| 118 | + | ) | |
| 119 | + | .await; | |
| 120 | + | let keys = keys.unwrap_or_else(|error| { | |
| 121 | + | worker::console_error!("ssh_key_owners failed: {error}"); | |
| 122 | + | HashMap::new() | |
| 123 | + | }); | |
| 124 | + | let owners = owners | |
| 125 | + | .unwrap_or_default() | |
| 126 | + | .into_iter() | |
| 127 | + | .map(|(email, owner)| (email.to_lowercase(), (owner.id, owner.username))) | |
| 128 | + | .collect(); | |
| 129 | + | (keys, owners) | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | /// Judges changes made through g1t (not a push), records how each | |
| 133 | + | /// ruleset judged them, and says whether they may go ahead. | |
| 134 | + | pub(crate) async fn check_changes(&self, repo: &Repo, actor: &User, action: Action, changes: Vec<RefChange>) -> Result<Ruled> { | |
| 135 | + | if repo.fork_of.is_some() || changes.is_empty() { | |
| 136 | + | return Ok(Ruled::Allowed); | |
| 137 | + | } | |
| 138 | + | let refs: Vec<String> = changes.iter().map(|change| change.git_ref.clone()).collect(); | |
| 139 | + | let rules = match self.ref_rules(repo, Some(actor), refs).await { | |
| 140 | + | Ok(Some(rules)) => rules, | |
| 141 | + | Ok(None) => return Ok(Ruled::Allowed), | |
| 142 | + | Err(error) => { | |
| 143 | + | worker::console_error!("ref_rules failed: {error}"); | |
| 144 | + | return Ok(Ruled::Refused { | |
| 145 | + | message: "The rules for this branch could not be checked just now. Try again in a moment.".to_owned(), | |
| 146 | + | }); | |
| 147 | + | } | |
| 148 | + | }; | |
| 149 | + | if rules.rulesets.is_empty() { | |
| 150 | + | return Ok(Ruled::Allowed); | |
| 151 | + | } | |
| 152 | + | let facts = who(Some(actor), repo); | |
| 153 | + | let judged: Vec<Judged> = changes | |
| 154 | + | .iter() | |
| 155 | + | .flat_map(|change| judge(&rules.rulesets, &rules.default_branch, facts.kind, change)) | |
| 156 | + | .collect(); | |
| 157 | + | let sha = changes.iter().find_map(|change| change.new.clone()); | |
| 158 | + | self.record_judged(repo, &judged, action, &facts, sha.as_deref()).await; | |
| 159 | + | if !outcome::refused(&judged) { | |
| 160 | + | return Ok(Ruled::Allowed); | |
| 161 | + | } | |
| 162 | + | let message = report::summary(&outcome::blocking(&judged)).unwrap_or_else(|| "Rules for this branch refuse it.".to_owned()); | |
| 163 | + | Ok(Ruled::Refused { message }) | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | /// Rules for a push: the response declining it, or `None` to let it | |
| 167 | + | /// on. `body` is as much of the push as was read; `whole` says whether | |
| 168 | + | /// that is all of it, so that its commits can be read. | |
| 169 | + | pub(crate) async fn check_push(&self, repo: &Repo, pusher: Option<&User>, body: &[u8], whole: bool) -> Result<Option<Response>> { | |
| 170 | + | if repo.fork_of.is_some() { | |
| 171 | + | return Ok(None); | |
| 172 | + | } | |
| 173 | + | let updates = crate::git_http::ref_updates(body); | |
| 174 | + | if updates.is_empty() { | |
| 175 | + | return Ok(None); | |
| 176 | + | } | |
| 177 | + | let refs: Vec<String> = updates.iter().map(|(name, _, _)| name.clone()).collect(); | |
| 178 | + | let rules = match self.ref_rules(repo, pusher, refs).await { | |
| 179 | + | Ok(Some(rules)) => rules, | |
| 180 | + | // Without the work service, the old protection holds. | |
| 181 | + | Ok(None) => { | |
| 182 | + | let protected = repo.protected.then(|| repo.default_branch.clone()); | |
| 183 | + | return protected | |
| 184 | + | .and_then(|branch| crate::git_http::refusal(body, &branch)) | |
| 185 | + | .map(crate::git_http::report_response) | |
| 186 | + | .transpose(); | |
| 187 | + | } | |
| 188 | + | Err(error) => { | |
| 189 | + | worker::console_error!("ref_rules failed during a push: {error}"); | |
| 190 | + | return Ok(Some(crate::git_http::declined( | |
| 191 | + | body, | |
| 192 | + | "rules could not be checked", | |
| 193 | + | &["The rules for this repository could not be checked just now. Push again in a moment.".to_owned()], | |
| 194 | + | )?)); | |
| 195 | + | } | |
| 196 | + | }; | |
| 197 | + | if rules.rulesets.is_empty() { | |
| 198 | + | return Ok(None); | |
| 199 | + | } | |
| 200 | + | let facts = who(pusher, repo); | |
| 201 | + | let content = needs_commits(&rules.rulesets, facts.kind); | |
| 202 | + | let ancestry = needs_ancestry(&rules.rulesets); | |
| 203 | + | let git = self.store.open(&store_key(repo)).await?; | |
| 204 | + | // The pack, read when a rule needs what it holds. | |
| 205 | + | let pack = if whole && (content || ancestry) { | |
| 206 | + | match pack_start(body).map(|start| Pack::parse(&body[start..])) { | |
| 207 | + | Some(Ok(mut pack)) => { | |
| 208 | + | crate::secret_scan::supply_bases(&mut pack, &git).await?; | |
| 209 | + | Some(pack) | |
| 210 | + | } | |
| 211 | + | Some(Err(problem)) => { | |
| 212 | + | worker::console_error!("a push's pack could not be read for rules: {problem}"); | |
| 213 | + | None | |
| 214 | + | } | |
| 215 | + | // Nothing but deletions, or pointing refs at commits the | |
| 216 | + | // repository has: an empty pack. | |
| 217 | + | None => Pack::parse(crate::land::EMPTY_PACK).ok(), | |
| 218 | + | } | |
| 219 | + | } else { | |
| 220 | + | None | |
| 221 | + | }; | |
| 222 | + | let signatures = rules | |
| 223 | + | .rulesets | |
| 224 | + | .iter() | |
| 225 | + | .any(|ruleset| ruleset.rules.iter().any(|entry| matches!(entry.rule, Rule::RequiredSignatures(_)))); | |
| 226 | + | let mut changes = Vec::new(); | |
| 227 | + | for (git_ref, old, new) in updates { | |
| 228 | + | let mut change = RefChange { git_ref, old: old.clone(), new: new.clone(), fast_forward: None, commits: Vec::new(), complete: false }; | |
| 229 | + | if let (Some(pack), Some(new)) = (&pack, &new) { | |
| 230 | + | if let Some(old) = &old | |
| 231 | + | && ancestry | |
| 232 | + | { | |
| 233 | + | change.fast_forward = Some(rule_facts::contains(pack, &git, new, old, MAX_ANCESTRY).await?); | |
| 234 | + | } | |
| 235 | + | if content { | |
| 236 | + | match rule_facts::added(pack, new, MAX_COMMITS) { | |
| 237 | + | Some(ids) => { | |
| 238 | + | let owners = if signatures { | |
| 239 | + | let (fingerprints, emails) = rule_facts::signing_facts(pack, &ids); | |
| 240 | + | Some(self.signing_owners(&fingerprints, &emails).await) | |
| 241 | + | } else { | |
| 242 | + | None | |
| 243 | + | }; | |
| 244 | + | change.commits = rule_facts::read_all(pack, &git, &ids, owners.as_ref()).await?; | |
| 245 | + | change.complete = change.commits.iter().all(|commit| commit.files_complete); | |
| 246 | + | } | |
| 247 | + | None => change.complete = false, | |
| 248 | + | } | |
| 249 | + | } else { | |
| 250 | + | change.complete = true; | |
| 251 | + | } | |
| 252 | + | } else if new.is_none() || !content { | |
| 253 | + | change.complete = true; | |
| 254 | + | } | |
| 255 | + | changes.push(change); | |
| 256 | + | } | |
| 257 | + | let judged: Vec<Judged> = changes | |
| 258 | + | .iter() | |
| 259 | + | .flat_map(|change| judge(&rules.rulesets, &rules.default_branch, facts.kind, change)) | |
| 260 | + | .collect(); | |
| 261 | + | let sha = changes.iter().find_map(|change| change.new.clone()); | |
| 262 | + | self.record_judged(repo, &judged, Action::Push, &facts, sha.as_deref()).await; | |
| 263 | + | if !outcome::refused(&judged) { | |
| 264 | + | return Ok(None); | |
| 265 | + | } | |
| 266 | + | let refused_ref = judged.iter().find(|one| one.blocks()).map(|one| one.git_ref.clone()).unwrap_or_default(); | |
| 267 | + | let lines = report::remote_lines(&refused_ref, &judged, &rules_url(repo, &refused_ref)); | |
| 268 | + | Ok(Some(crate::git_http::declined(body, &report::ng_reason(&judged), &lines)?)) | |
| 269 | + | } | |
| 270 | + | ||
| 271 | + | /// Services only: the commits a pull request would land, read as rules | |
| 272 | + | /// look at them, fetched from its source as a pack. | |
| 273 | + | pub(crate) async fn inspect_commits(&self, a: InspectCommitsArgs) -> Result<Outcome<InspectedCommits>> { | |
| 274 | + | let (Some(source), Some(target)) = (self.registry.by_id(&a.source_id).await?, self.registry.by_id(&a.target_id).await?) else { | |
| 275 | + | return Ok(Outcome::fail(FailureCode::NotFound, "Repository not found.")); | |
| 276 | + | }; | |
| 277 | + | self.live(&source).await?; | |
| 278 | + | let (source_git, target_git) = (self.store.open(&store_key(&source)).await?, self.store.open(&store_key(&target)).await?); | |
| 279 | + | let (history, base_history) = | |
| 280 | + | futures_util::future::try_join(source_git.log(&a.head, MAX_ANCESTRY), target_git.log(&a.base_branch, MAX_ANCESTRY)).await?; | |
| 281 | + | let shared: std::collections::HashSet<String> = base_history.into_iter().map(|commit| commit.hash).collect(); | |
| 282 | + | let merge_base = crate::nearest_ancestor_in(&source_git, &history, &shared).await?; | |
| 283 | + | let Some(head) = history.first() else { | |
| 284 | + | return Ok(Outcome::Ok(InspectedCommits { commits: Vec::new(), complete: true })); | |
| 285 | + | }; | |
| 286 | + | let access = source_git.access(crate::store::Scope::Read).await?; | |
| 287 | + | let fetched = crate::land::fetch_pack(&access, &head.hash, merge_base.as_deref()).await?; | |
| 288 | + | if fetched.len() > crate::secret_scan::MAX_SCANNED_PUSH { | |
| 289 | + | return Ok(Outcome::Ok(InspectedCommits { commits: Vec::new(), complete: false })); | |
| 290 | + | } | |
| 291 | + | let pack = match Pack::parse(&fetched) { | |
| 292 | + | Ok(pack) => pack, | |
| 293 | + | Err(problem) => { | |
| 294 | + | worker::console_error!("a pull request's commits could not be read for rules: {problem}"); | |
| 295 | + | return Ok(Outcome::Ok(InspectedCommits { commits: Vec::new(), complete: false })); | |
| 296 | + | } | |
| 297 | + | }; | |
| 298 | + | let limit = a.limit.map_or(MAX_COMMITS, |limit| (limit as usize).min(MAX_COMMITS)); | |
| 299 | + | let Some(ids) = rule_facts::added(&pack, &head.hash, limit) else { | |
| 300 | + | return Ok(Outcome::Ok(InspectedCommits { commits: Vec::new(), complete: false })); | |
| 301 | + | }; | |
| 302 | + | let (fingerprints, emails) = rule_facts::signing_facts(&pack, &ids); | |
| 303 | + | let owners = self.signing_owners(&fingerprints, &emails).await; | |
| 304 | + | let commits = rule_facts::read_all(&pack, &source_git, &ids, Some(&owners)).await?; | |
| 305 | + | let complete = commits.iter().all(|commit| commit.files_complete); | |
| 306 | + | Ok(Outcome::Ok(InspectedCommits { commits, complete })) | |
| 307 | + | } | |
| 308 | + | } | |
| 309 | + | ||
| 310 | + | /// The facts of one commit g1t makes itself (a web edit, a catch-up | |
| 311 | + | /// merge): it is not signed, and it changes `files`. | |
| 312 | + | pub(crate) fn made_commit(sha: &str, message: &str, email: &str, parents: u32, files: Vec<FileChange>) -> CommitFacts { | |
| 313 | + | CommitFacts { | |
| 314 | + | sha: sha.to_owned(), | |
| 315 | + | message: message.to_owned(), | |
| 316 | + | author_email: Some(email.to_owned()), | |
| 317 | + | committer_email: Some(email.to_owned()), | |
| 318 | + | parents, | |
| 319 | + | signature: g1t_contracts::rules::Signature::Unsigned, | |
| 320 | + | files, | |
| 321 | + | files_complete: true, | |
| 322 | + | } | |
| 323 | + | } | |
| 324 | + | ||
| 325 | + | #[cfg(test)] | |
| 326 | + | mod tests { | |
| 327 | + | use super::*; | |
| 328 | + | use g1t_contracts::rules::{AppliesTo, Level, NoParameters, RefCondition, RuleEntry}; | |
| 329 | + | ||
| 330 | + | fn repo() -> Repo { | |
| 331 | + | serde_json::from_value(serde_json::json!({ | |
| 332 | + | "id": "rep_1", "namespace": "acme", "name": "web", "description": null, "isPrivate": false, | |
| 333 | + | "ownerId": "usr_1", "defaultBranch": "main", "forkOf": null, "createdAt": "" | |
| 334 | + | })) | |
| 335 | + | .unwrap() | |
| 336 | + | } | |
| 337 | + | ||
| 338 | + | fn ruleset(enforcement: Enforcement, rule: Rule, applies_to: AppliesTo) -> Applicable { | |
| 339 | + | Applicable { | |
| 340 | + | id: "rs_1".into(), | |
| 341 | + | name: "R".into(), | |
| 342 | + | level: Level::Repository, | |
| 343 | + | enforcement, | |
| 344 | + | target: Target::Branch, | |
| 345 | + | conditions: RefCondition { include: vec!["~ALL".into()], exclude: Vec::new() }, | |
| 346 | + | rules: vec![RuleEntry { rule, applies_to }], | |
| 347 | + | bypass: None, | |
| 348 | + | } | |
| 349 | + | } | |
| 350 | + | ||
| 351 | + | #[test] | |
| 352 | + | fn rules_are_linked_by_branch_or_tag() { | |
| 353 | + | assert_eq!(rules_url(&repo(), "refs/heads/release/1"), "https://g1t.sh/acme/web/settings/rules?branch=release/1"); | |
| 354 | + | assert_eq!(rules_url(&repo(), "refs/tags/v1"), "https://g1t.sh/acme/web/settings/rules?tag=v1"); | |
| 355 | + | } | |
| 356 | + | ||
| 357 | + | #[test] | |
| 358 | + | fn commits_are_read_only_when_a_rule_for_this_actor_needs_them() { | |
| 359 | + | let signed = ruleset(Enforcement::Evaluate, Rule::RequiredSignatures(NoParameters {}), AppliesTo::Everyone); | |
| 360 | + | assert!(needs_commits(&[signed], Who::Person), "evaluate-mode rulesets are recorded too"); | |
| 361 | + | let agents = ruleset(Enforcement::Active, Rule::RequiredSignatures(NoParameters {}), AppliesTo::Agents); | |
| 362 | + | assert!(!needs_commits(std::slice::from_ref(&agents), Who::Person)); | |
| 363 | + | assert!(needs_commits(&[agents], Who::Agent)); | |
| 364 | + | let off = ruleset(Enforcement::Disabled, Rule::RequiredSignatures(NoParameters {}), AppliesTo::Everyone); | |
| 365 | + | assert!(!needs_commits(&[off], Who::Person)); | |
| 366 | + | assert!(needs_ancestry(&[ruleset(Enforcement::Active, Rule::NonFastForward(NoParameters {}), AppliesTo::Everyone)])); | |
| 367 | + | } | |
| 368 | + | ||
| 369 | + | #[test] | |
| 370 | + | fn a_commit_g1t_makes_is_unsigned_and_lists_its_files() { | |
| 371 | + | let made = made_commit("abc", "Add CI", "ada@acme.com", 1, vec![FileChange { path: ".g1t/workflows/ci.yml".into(), size: Some(12), deleted: false }]); | |
| 372 | + | assert_eq!(made.signature, g1t_contracts::rules::Signature::Unsigned); | |
| 373 | + | assert!(made.files_complete); | |
| 374 | + | } | |
| 375 | + | } |
| 71 | 71 | } | |
| 72 | 72 | ||
| 73 | 73 | /// Objects for a walk: the pushed pack's first, then the repository's. | |
| 74 | − | struct Objects<'a, R: GitRepo> { | |
| 75 | − | pack: &'a Pack, | |
| 76 | − | repo: &'a R, | |
| 77 | − | reads: Cell<u32>, | |
| 74 | + | pub(crate) struct Objects<'a, R: GitRepo> { | |
| 75 | + | pub(crate) pack: &'a Pack, | |
| 76 | + | pub(crate) repo: &'a R, | |
| 77 | + | pub(crate) reads: Cell<u32>, | |
| 78 | 78 | } | |
| 79 | 79 | ||
| 80 | 80 | impl<R: GitRepo> Objects<'_, R> { | |
| 81 | − | async fn tree(&self, id: &str) -> Result<Vec<TreeItem>> { | |
| 81 | + | pub(crate) async fn tree(&self, id: &str) -> Result<Vec<TreeItem>> { | |
| 82 | 82 | if let Some(items) = self.pack.tree(id) { | |
| 83 | 83 | return Ok(items); | |
| 84 | 84 | } | |
| ⋯ | |||
| 101 | 101 | self.repo.read_blob(id).await | |
| 102 | 102 | } | |
| 103 | 103 | ||
| 104 | − | async fn commit_tree(&self, id: &str) -> Result<Option<String>> { | |
| 104 | + | pub(crate) async fn commit_tree(&self, id: &str) -> Result<Option<String>> { | |
| 105 | 105 | if let Some(commit) = self.pack.commit(id) { | |
| 106 | 106 | return Ok(Some(commit.tree)); | |
| 107 | 107 | } | |
| ⋯ | |||
| 257 | 257 | } | |
| 258 | 258 | ||
| 259 | 259 | /// Fetches what a thin pack's deltas are based on from the repository. | |
| 260 | − | async fn supply_bases<R: GitRepo>(pack: &mut Pack, repo: &R) -> Result<()> { | |
| 260 | + | pub(crate) async fn supply_bases<R: GitRepo>(pack: &mut Pack, repo: &R) -> Result<()> { | |
| 261 | 261 | for _ in 0..3 { | |
| 262 | 262 | let missing = pack.missing_bases(); | |
| 263 | 263 | if missing.is_empty() { | |
| 1 | + | //! Commit signatures, for the "Require signed commits" rule. | |
| 2 | + | //! | |
| 3 | + | //! A commit signed with an SSH key carries an `SSHSIG` signature in its | |
| 4 | + | //! `gpgsig` header (git's `gpg.format ssh`). It is verified here when the | |
| 5 | + | //! key is ed25519: the signature must be valid over the commit without | |
| 6 | + | //! that header, in the `git` namespace, and the key must be registered on | |
| 7 | + | //! the g1t account that owns the committer's verified email address. | |
| 8 | + | //! Signatures with other key types and GPG signatures are reported as not | |
| 9 | + | //! verified, with why. | |
| 10 | + | ||
| 11 | + | use std::collections::HashMap; | |
| 12 | + | ||
| 13 | + | use base64::Engine; | |
| 14 | + | use base64::engine::general_purpose::{STANDARD, STANDARD_NO_PAD}; | |
| 15 | + | use ed25519_dalek::{Signature as Ed25519Signature, Verifier, VerifyingKey}; | |
| 16 | + | use g1t_contracts::rules::Signature; | |
| 17 | + | use sha2::{Digest, Sha256, Sha512}; | |
| 18 | + | ||
| 19 | + | /// A commit's signature as found in its object: the armored text, and the | |
| 20 | + | /// bytes it signs (the commit without its `gpgsig` header). | |
| 21 | + | #[derive(Debug, PartialEq, Eq)] | |
| 22 | + | pub struct Signed { | |
| 23 | + | pub armored: String, | |
| 24 | + | pub payload: Vec<u8>, | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | /// The signature in a raw commit object, if it has one. | |
| 28 | + | pub fn signed(commit: &[u8]) -> Option<Signed> { | |
| 29 | + | let text = std::str::from_utf8(commit).ok()?; | |
| 30 | + | let (headers, message) = text.split_once("\n\n").unwrap_or((text, "")); | |
| 31 | + | let mut payload = String::with_capacity(text.len()); | |
| 32 | + | let mut armored = String::new(); | |
| 33 | + | let mut in_signature = false; | |
| 34 | + | for line in headers.split('\n') { | |
| 35 | + | if let Some(first) = line.strip_prefix("gpgsig ").or_else(|| line.strip_prefix("gpgsig-sha256 ")) { | |
| 36 | + | in_signature = true; | |
| 37 | + | armored.push_str(first); | |
| 38 | + | armored.push('\n'); | |
| 39 | + | continue; | |
| 40 | + | } | |
| 41 | + | if in_signature && let Some(more) = line.strip_prefix(' ') { | |
| 42 | + | armored.push_str(more); | |
| 43 | + | armored.push('\n'); | |
| 44 | + | continue; | |
| 45 | + | } | |
| 46 | + | in_signature = false; | |
| 47 | + | payload.push_str(line); | |
| 48 | + | payload.push('\n'); | |
| 49 | + | } | |
| 50 | + | if armored.is_empty() { | |
| 51 | + | return None; | |
| 52 | + | } | |
| 53 | + | payload.push('\n'); | |
| 54 | + | payload.push_str(message); | |
| 55 | + | Some(Signed { armored, payload: payload.into_bytes() }) | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | /// What reading an SSH signature found: the key's fingerprint, as | |
| 59 | + | /// `ssh-keygen -lf` prints it, once the signature checks out. | |
| 60 | + | #[derive(Debug, PartialEq, Eq)] | |
| 61 | + | pub enum Checked { | |
| 62 | + | /// Valid; owned by whoever registered this key. | |
| 63 | + | Valid { fingerprint: String }, | |
| 64 | + | Invalid(String), | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | fn take<'a>(bytes: &mut &'a [u8], count: usize) -> Option<&'a [u8]> { | |
| 68 | + | if bytes.len() < count { | |
| 69 | + | return None; | |
| 70 | + | } | |
| 71 | + | let (head, rest) = bytes.split_at(count); | |
| 72 | + | *bytes = rest; | |
| 73 | + | Some(head) | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | fn string<'a>(bytes: &mut &'a [u8]) -> Option<&'a [u8]> { | |
| 77 | + | let length = u32::from_be_bytes(take(bytes, 4)?.try_into().ok()?) as usize; | |
| 78 | + | take(bytes, length) | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | fn put_string(out: &mut Vec<u8>, value: &[u8]) { | |
| 82 | + | out.extend_from_slice(&(value.len() as u32).to_be_bytes()); | |
| 83 | + | out.extend_from_slice(value); | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | /// Checks an armored signature over `payload`. | |
| 87 | + | pub fn check(armored: &str, payload: &[u8]) -> Checked { | |
| 88 | + | let armored = armored.trim(); | |
| 89 | + | if armored.starts_with("-----BEGIN PGP SIGNATURE-----") { | |
| 90 | + | return Checked::Invalid("GPG signatures are not verified yet; sign with an SSH key (git config gpg.format ssh).".to_owned()); | |
| 91 | + | } | |
| 92 | + | if armored.starts_with("-----BEGIN SIGNED MESSAGE-----") { | |
| 93 | + | return Checked::Invalid("S/MIME signatures are not verified; sign with an SSH key (git config gpg.format ssh).".to_owned()); | |
| 94 | + | } | |
| 95 | + | let Some(body) = armored | |
| 96 | + | .strip_prefix("-----BEGIN SSH SIGNATURE-----") | |
| 97 | + | .and_then(|rest| rest.trim().strip_suffix("-----END SSH SIGNATURE-----")) | |
| 98 | + | else { | |
| 99 | + | return Checked::Invalid("the signature is in a format g1t does not read.".to_owned()); | |
| 100 | + | }; | |
| 101 | + | let encoded: String = body.chars().filter(|c| !c.is_whitespace()).collect(); | |
| 102 | + | let Ok(blob) = STANDARD.decode(encoded) else { | |
| 103 | + | return Checked::Invalid("the signature is not valid base64.".to_owned()); | |
| 104 | + | }; | |
| 105 | + | let invalid = |why: &str| Checked::Invalid(why.to_owned()); | |
| 106 | + | let mut rest = blob.as_slice(); | |
| 107 | + | if take(&mut rest, 6) != Some(b"SSHSIG".as_slice()) { | |
| 108 | + | return invalid("the signature is not an SSH signature."); | |
| 109 | + | } | |
| 110 | + | if take(&mut rest, 4).map(|version| u32::from_be_bytes(version.try_into().unwrap_or_default())) != Some(1) { | |
| 111 | + | return invalid("the signature's version is not one g1t reads."); | |
| 112 | + | } | |
| 113 | + | let (Some(public_key), Some(namespace), Some(reserved), Some(hash), Some(signature)) = | |
| 114 | + | (string(&mut rest), string(&mut rest), string(&mut rest), string(&mut rest), string(&mut rest)) | |
| 115 | + | else { | |
| 116 | + | return invalid("the signature is cut short."); | |
| 117 | + | }; | |
| 118 | + | if namespace != b"git" { | |
| 119 | + | return invalid("the signature is not for git commits (its namespace is not git)."); | |
| 120 | + | } | |
| 121 | + | let mut key = public_key; | |
| 122 | + | let (Some(key_type), Some(key_bytes)) = (string(&mut key), string(&mut key)) else { | |
| 123 | + | return invalid("the signing key could not be read."); | |
| 124 | + | }; | |
| 125 | + | if key_type != b"ssh-ed25519" { | |
| 126 | + | return Checked::Invalid(format!( | |
| 127 | + | "{} keys are not verified yet; sign with an ed25519 key.", | |
| 128 | + | String::from_utf8_lossy(key_type) | |
| 129 | + | )); | |
| 130 | + | } | |
| 131 | + | let mut sig = signature; | |
| 132 | + | let (Some(sig_type), Some(sig_bytes)) = (string(&mut sig), string(&mut sig)) else { | |
| 133 | + | return invalid("the signature could not be read."); | |
| 134 | + | }; | |
| 135 | + | if sig_type != b"ssh-ed25519" { | |
| 136 | + | return invalid("the signature does not match its key's type."); | |
| 137 | + | } | |
| 138 | + | let digest: Vec<u8> = match hash { | |
| 139 | + | b"sha512" => Sha512::digest(payload).to_vec(), | |
| 140 | + | b"sha256" => Sha256::digest(payload).to_vec(), | |
| 141 | + | _ => return invalid("the signature uses a hash g1t does not read."), | |
| 142 | + | }; | |
| 143 | + | let mut signed_data = b"SSHSIG".to_vec(); | |
| 144 | + | put_string(&mut signed_data, namespace); | |
| 145 | + | put_string(&mut signed_data, reserved); | |
| 146 | + | put_string(&mut signed_data, hash); | |
| 147 | + | put_string(&mut signed_data, &digest); | |
| 148 | + | let (Ok(key_bytes), Ok(sig_bytes)) = (<[u8; 32]>::try_from(key_bytes), <[u8; 64]>::try_from(sig_bytes)) else { | |
| 149 | + | return invalid("the key or signature has the wrong length."); | |
| 150 | + | }; | |
| 151 | + | let Ok(verifying) = VerifyingKey::from_bytes(&key_bytes) else { | |
| 152 | + | return invalid("the signing key is not a valid ed25519 key."); | |
| 153 | + | }; | |
| 154 | + | if verifying.verify(&signed_data, &Ed25519Signature::from_bytes(&sig_bytes)).is_err() { | |
| 155 | + | return invalid("the signature does not match the commit."); | |
| 156 | + | } | |
| 157 | + | Checked::Valid { fingerprint: format!("SHA256:{}", STANDARD_NO_PAD.encode(Sha256::digest(public_key))) } | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | /// A commit's signature, decided: `key_owners` maps fingerprints to the | |
| 161 | + | /// account (user id) that registered the key, `email_owners` the | |
| 162 | + | /// committer's address to the account (id, username) that verified it. | |
| 163 | + | pub fn decide( | |
| 164 | + | commit: &[u8], | |
| 165 | + | committer_email: Option<&str>, | |
| 166 | + | key_owners: &HashMap<String, String>, | |
| 167 | + | email_owners: &HashMap<String, (String, String)>, | |
| 168 | + | ) -> Signature { | |
| 169 | + | let Some(signed) = signed(commit) else { | |
| 170 | + | return Signature::Unsigned; | |
| 171 | + | }; | |
| 172 | + | match check(&signed.armored, &signed.payload) { | |
| 173 | + | Checked::Invalid(reason) => Signature::Unverified { reason }, | |
| 174 | + | Checked::Valid { fingerprint } => { | |
| 175 | + | let Some(key_owner) = key_owners.get(&fingerprint) else { | |
| 176 | + | return Signature::Unverified { | |
| 177 | + | reason: format!("the key {fingerprint} is not registered on any g1t account."), | |
| 178 | + | }; | |
| 179 | + | }; | |
| 180 | + | let email = committer_email.unwrap_or_default().to_lowercase(); | |
| 181 | + | match email_owners.get(&email) { | |
| 182 | + | Some((id, username)) if id == key_owner => Signature::Verified { signer: username.clone() }, | |
| 183 | + | Some(_) => Signature::Unverified { | |
| 184 | + | reason: format!("the key is not registered on the account that owns {email}."), | |
| 185 | + | }, | |
| 186 | + | None => Signature::Unverified { reason: format!("{email} is not a verified email address on g1t.") }, | |
| 187 | + | } | |
| 188 | + | } | |
| 189 | + | } | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | /// The fingerprint a valid SSH signature was made with, to look up its | |
| 193 | + | /// owner; `None` for anything else. | |
| 194 | + | pub fn fingerprint(commit: &[u8]) -> Option<String> { | |
| 195 | + | let signed = signed(commit)?; | |
| 196 | + | match check(&signed.armored, &signed.payload) { | |
| 197 | + | Checked::Valid { fingerprint } => Some(fingerprint), | |
| 198 | + | Checked::Invalid(_) => None, | |
| 199 | + | } | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | #[cfg(test)] | |
| 203 | + | mod tests { | |
| 204 | + | use super::*; | |
| 205 | + | ||
| 206 | + | /// Made with `ssh-keygen -Y sign -n git` and a throwaway ed25519 key. | |
| 207 | + | const PAYLOAD: &str = "tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\nauthor Ada <ada@acme.com> 1759800000 +0000\ncommitter Ada <ada@acme.com> 1759800000 +0000\n\nAdd rules\n"; | |
| 208 | + | const SIGNATURE: &str = "-----BEGIN SSH SIGNATURE----- | |
| 209 | + | U1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgtVGsjkmUevm9NOrwhStbNZ7Njn | |
| 210 | + | RXkKOw20fds1RA0lwAAAADZ2l0AAAAAAAAAAZzaGE1MTIAAABTAAAAC3NzaC1lZDI1NTE5 | |
| 211 | + | AAAAQHcrDRd94FoOk8mWAMZL9v2urJlYdG5OVKiwlbVcgFuc9qmOeP+8ivMA4duwWx0N8P | |
| 212 | + | NP89FV6u51GZJ+01SZ5Ag= | |
| 213 | + | -----END SSH SIGNATURE-----"; | |
| 214 | + | const PUBLIC_KEY: &str = "AAAAC3NzaC1lZDI1NTE5AAAAILVRrI5JlHr5vTTq8IUrWzWezY50V5CjsNtH3bNUQNJc"; | |
| 215 | + | ||
| 216 | + | /// The commit as git writes it: the signature in a `gpgsig` header, | |
| 217 | + | /// each line after its first indented by a space. | |
| 218 | + | fn commit(message: &str) -> Vec<u8> { | |
| 219 | + | let (headers, _) = PAYLOAD.split_once("\n\n").unwrap(); | |
| 220 | + | let sig = SIGNATURE.lines().collect::<Vec<_>>().join("\n "); | |
| 221 | + | format!("{headers}\ngpgsig {sig}\n\n{message}").into_bytes() | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | fn fingerprint_of_key() -> String { | |
| 225 | + | format!("SHA256:{}", STANDARD_NO_PAD.encode(Sha256::digest(STANDARD.decode(PUBLIC_KEY).unwrap()))) | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | #[test] | |
| 229 | + | fn the_payload_is_the_commit_without_its_signature() { | |
| 230 | + | let found = signed(&commit("Add rules\n")).unwrap(); | |
| 231 | + | assert_eq!(String::from_utf8(found.payload).unwrap(), PAYLOAD); | |
| 232 | + | assert!(found.armored.starts_with("-----BEGIN SSH SIGNATURE-----\nU1NIU0lH")); | |
| 233 | + | assert_eq!(signed(PAYLOAD.as_bytes()), None); | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | #[test] | |
| 237 | + | fn a_good_ed25519_signature_checks_out() { | |
| 238 | + | assert_eq!( | |
| 239 | + | check(SIGNATURE, PAYLOAD.as_bytes()), | |
| 240 | + | Checked::Valid { fingerprint: fingerprint_of_key() } | |
| 241 | + | ); | |
| 242 | + | assert_eq!(fingerprint(&commit("Add rules\n")), Some(fingerprint_of_key())); | |
| 243 | + | } | |
| 244 | + | ||
| 245 | + | #[test] | |
| 246 | + | fn a_changed_commit_does_not() { | |
| 247 | + | assert_eq!( | |
| 248 | + | check(SIGNATURE, b"tree 0000\n\nSomething else\n"), | |
| 249 | + | Checked::Invalid("the signature does not match the commit.".into()) | |
| 250 | + | ); | |
| 251 | + | assert_eq!(fingerprint(&commit("Add rules, sneakily\n")), None); | |
| 252 | + | } | |
| 253 | + | ||
| 254 | + | #[test] | |
| 255 | + | fn gpg_and_unknown_formats_are_not_verified() { | |
| 256 | + | assert!(matches!(check("-----BEGIN PGP SIGNATURE-----\nabc\n-----END PGP SIGNATURE-----", b"x"), Checked::Invalid(why) if why.starts_with("GPG signatures"))); | |
| 257 | + | assert!(matches!(check("junk", b"x"), Checked::Invalid(_))); | |
| 258 | + | assert!(matches!(check("-----BEGIN SSH SIGNATURE-----\n!!!\n-----END SSH SIGNATURE-----", b"x"), Checked::Invalid(_))); | |
| 259 | + | } | |
| 260 | + | ||
| 261 | + | #[test] | |
| 262 | + | fn verified_means_the_key_belongs_to_whoever_owns_the_committer_address() { | |
| 263 | + | let commit = commit("Add rules\n"); | |
| 264 | + | let mut keys = HashMap::new(); | |
| 265 | + | let mut emails = HashMap::new(); | |
| 266 | + | assert!(matches!(decide(&commit, Some("ada@acme.com"), &keys, &emails), Signature::Unverified { reason } if reason.contains("not registered"))); | |
| 267 | + | keys.insert(fingerprint_of_key(), "usr_ada".to_owned()); | |
| 268 | + | assert!(matches!(decide(&commit, Some("ada@acme.com"), &keys, &emails), Signature::Unverified { reason } if reason.contains("not a verified email"))); | |
| 269 | + | emails.insert("ada@acme.com".to_owned(), ("usr_eve".to_owned(), "eve".to_owned())); | |
| 270 | + | assert!(matches!(decide(&commit, Some("Ada@acme.com"), &keys, &emails), Signature::Unverified { reason } if reason.contains("not registered on the account"))); | |
| 271 | + | emails.insert("ada@acme.com".to_owned(), ("usr_ada".to_owned(), "ada".to_owned())); | |
| 272 | + | assert_eq!(decide(&commit, Some("ada@acme.com"), &keys, &emails), Signature::Verified { signer: "ada".into() }); | |
| 273 | + | assert_eq!(decide(PAYLOAD.as_bytes(), Some("ada@acme.com"), &keys, &emails), Signature::Unsigned); | |
| 274 | + | } | |
| 275 | + | } |
| 53 | 53 | { "binding": "SECURITY", "service": "g1t-security" }, | |
| 54 | 54 | // Whether a workspace far past its free git operations is on the | |
| 55 | 55 | // plan (src/git_ops.rs); asked only then. | |
| 56 | − | { "binding": "BILLING", "service": "g1t-billing" } | |
| 56 | + | { "binding": "BILLING", "service": "g1t-billing" }, | |
| 57 | + | // Rulesets: which hold for a push or another change to a branch or | |
| 58 | + | // tag, and where how they judged it is kept (src/rules.rs). | |
| 59 | + | { "binding": "WORK", "service": "g1t-work" } | |
| 57 | 60 | ], | |
| 58 | 61 | // A free workspace past this many git operations in a month is slowed | |
| 59 | 62 | // to this many an hour, never charged (src/git_ops.rs). It is billing's |
| 64 | 64 | } | |
| 65 | 65 | ||
| 66 | 66 | /// The workspace an event belongs to when it is about no repository: a | |
| 67 | − | /// package of the workspace's own, unlinked from any repository, or one of | |
| 68 | − | /// its teams. Such an event goes to the workspace's webhooks only. Events | |
| 69 | − | /// about a repository (a team given a role on one among them), and every | |
| 70 | − | /// other kind, are `None`: they are routed by their repository. | |
| 67 | + | /// package of the workspace's own, unlinked from any repository, one of | |
| 68 | + | /// its teams, or one of its rulesets. Such an event goes to the | |
| 69 | + | /// workspace's webhooks only. Events about a repository (a team given a | |
| 70 | + | /// role on one among them, a repository's own ruleset), and every other | |
| 71 | + | /// kind, are `None`: they are routed by their repository. | |
| 71 | 72 | pub fn workspace_scoped(event: &Event) -> Option<String> { | |
| 72 | − | let own = event.kind.starts_with("package.") || event.kind.starts_with("team."); | |
| 73 | + | let own = event.kind.starts_with("package.") || event.kind.starts_with("team.") || event.kind.starts_with("ruleset."); | |
| 73 | 74 | if event.repo_id.as_deref().is_some_and(|id| !id.is_empty()) || !own { | |
| 74 | 75 | return None; | |
| 75 | 76 | } | |
| ⋯ | |||
| 246 | 247 | assert_eq!(workspace_scoped(&team).as_deref(), Some("acme")); | |
| 247 | 248 | let granted = repo_event("team.repo_added", json!({ "workspace": "acme", "repoId": "rep_1" })); | |
| 248 | 249 | assert_eq!(workspace_scoped(&granted), None); | |
| 250 | + | // A workspace's ruleset is the workspace's; a repository's goes by it. | |
| 251 | + | let mut ruleset = repo_event("ruleset.updated", json!({ "workspace": "acme", "ruleset": {} })); | |
| 252 | + | ruleset.repo_id = None; | |
| 253 | + | assert_eq!(workspace_scoped(&ruleset).as_deref(), Some("acme")); | |
| 254 | + | let own = repo_event("ruleset.created", json!({ "workspace": "acme", "repository": "acme/web" })); | |
| 255 | + | assert_eq!(workspace_scoped(&own), None); | |
| 249 | 256 | // Other events without a repository are not workspace events. | |
| 250 | 257 | let mut other = repo_event("issue.opened", json!({ "workspace": "acme" })); | |
| 251 | 258 | other.repo_id = None; | |