| 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; |
| 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 ask of a push, before its pack is read. |
| 34 | pub(crate) enum PushRules { |
| 35 | /// Nothing to judge: a pull request's working copy, a push that changes |
| 36 | /// no branch or tag, or one no ruleset holds for. |
| 37 | Nothing, |
| 38 | /// Answered without the pack: by the old protection, where rulesets |
| 39 | /// cannot be read, or declined because they could not be read just now. |
| 40 | Answered(Result<Option<Response>>), |
| 41 | /// The rulesets to judge it by, and the branches and tags it changes. |
| 42 | Judge { rules: RefRules, updates: Vec<(String, Option<String>, Option<String>)> }, |
| 43 | /// Judged, once its pack was read (push_checks.rs). |
| 44 | Judged(PushJudged), |
| 45 | } |
| 46 | |
| 47 | /// How the rules judged a push, and the response declining it, if they did. |
| 48 | pub(crate) struct PushJudged { |
| 49 | facts: ActorFacts, |
| 50 | judged: Vec<Judged>, |
| 51 | sha: Option<String>, |
| 52 | pub(crate) refusal: Option<Response>, |
| 53 | } |
| 54 | |
| 55 | /// What the rules said about a change. |
| 56 | pub(crate) enum Ruled { |
| 57 | /// No ruleset holds, or none refuses it. |
| 58 | Allowed, |
| 59 | /// Refused: why, in a sentence. |
| 60 | Refused { message: String }, |
| 61 | } |
| 62 | |
| 63 | /// `ssh_key_owners` on identity: the account that registered each key. |
| 64 | #[derive(serde::Serialize)] |
| 65 | struct KeyOwnersArgs<'a> { |
| 66 | fingerprints: &'a [String], |
| 67 | } |
| 68 | |
| 69 | fn who(actor: Option<&User>, repo: &Repo) -> ActorFacts { |
| 70 | match actor { |
| 71 | Some(actor) => ActorFacts::of(actor, repo), |
| 72 | None => ActorFacts { username: "anonymous".to_owned(), ..ActorFacts::default() }, |
| 73 | } |
| 74 | } |
| 75 | |
| 76 | /// Whether any ruleset that is evaluated (active, or evaluate) has a rule |
| 77 | /// about commits that holds for this actor. |
| 78 | fn needs_commits(rulesets: &[Applicable], kind: Who) -> bool { |
| 79 | rulesets.iter().filter(|ruleset| ruleset.enforcement != Enforcement::Disabled).any(|ruleset| { |
| 80 | ruleset |
| 81 | .rules |
| 82 | .iter() |
| 83 | .any(|entry| entry.applies_to.covers(kind.is_agent()) && content::about_content(&entry.rule)) |
| 84 | }) |
| 85 | } |
| 86 | |
| 87 | fn needs_ancestry(rulesets: &[Applicable]) -> bool { |
| 88 | rulesets |
| 89 | .iter() |
| 90 | .any(|ruleset| ruleset.rules.iter().any(|entry| matches!(entry.rule, Rule::NonFastForward(_)))) |
| 91 | } |
| 92 | |
| 93 | /// Where the rules of a ref are shown. |
| 94 | pub(crate) fn rules_url(repo: &Repo, git_ref: &str) -> String { |
| 95 | let (target, name) = Target::of_ref(git_ref).unwrap_or((Target::Branch, git_ref)); |
| 96 | let key = if target == Target::Tag { "tag" } else { "branch" }; |
| 97 | format!("{SITE}/{}/{}/settings/rules?{key}={name}", repo.namespace, repo.name) |
| 98 | } |
| 99 | |
| 100 | impl<S: GitStore> Repos<S> { |
| 101 | /// The rulesets that hold for `refs`, from the work service. `None` |
| 102 | /// when this installation runs without it. |
| 103 | async fn ref_rules(&self, repo: &Repo, actor: Option<&User>, refs: Vec<String>) -> Result<Option<RefRules>> { |
| 104 | let Some(work) = &self.work else { return Ok(None) }; |
| 105 | let found: Outcome<RefRules> = |
| 106 | g1t_kit::call(work, "ref_rules", &RefRulesArgs { repo: repo.clone(), actor: actor.cloned(), refs }).await?; |
| 107 | match found { |
| 108 | Outcome::Ok(rules) => Ok(Some(rules)), |
| 109 | Outcome::Fail(failure) => Err(worker::Error::RustError(failure.message)), |
| 110 | } |
| 111 | } |
| 112 | |
| 113 | /// Sends judgements to be recorded; a failure is logged. |
| 114 | async fn record_judged(&self, repo: &Repo, judged: &[Judged], action: Action, actor: &ActorFacts, sha: Option<&str>) { |
| 115 | let Some(work) = &self.work else { return }; |
| 116 | if judged.is_empty() { |
| 117 | return; |
| 118 | } |
| 119 | let evaluations = g1t_rules::evaluations(judged, &repo.id, &repo.namespace, action, actor, None, sha); |
| 120 | let recorded: Result<u32> = g1t_kit::call(work, "record_evaluations", &RecordEvaluationsArgs { evaluations }).await; |
| 121 | if let Err(error) = recorded { |
| 122 | worker::console_error!("rule evaluations not recorded: {error}"); |
| 123 | } |
| 124 | } |
| 125 | |
| 126 | /// Who owns the keys commits were signed with, and the addresses |
| 127 | /// they were committed as. |
| 128 | async fn signing_owners( |
| 129 | &self, |
| 130 | fingerprints: &[String], |
| 131 | emails: &[String], |
| 132 | ) -> (HashMap<String, String>, HashMap<String, (String, String)>) { |
| 133 | let Some(identity) = &self.identity else { return (HashMap::new(), HashMap::new()) }; |
| 134 | if fingerprints.is_empty() { |
| 135 | return (HashMap::new(), HashMap::new()); |
| 136 | } |
| 137 | let (keys, owners) = futures_util::future::join( |
| 138 | g1t_kit::call::<_, HashMap<String, String>>(identity, "ssh_key_owners", &KeyOwnersArgs { fingerprints }), |
| 139 | g1t_kit::call::<_, HashMap<String, EmailOwner>>(identity, "email_owners", &EmailOwnersArgs { emails: emails.to_vec() }), |
| 140 | ) |
| 141 | .await; |
| 142 | let keys = keys.unwrap_or_else(|error| { |
| 143 | worker::console_error!("ssh_key_owners failed: {error}"); |
| 144 | HashMap::new() |
| 145 | }); |
| 146 | let owners = owners |
| 147 | .unwrap_or_default() |
| 148 | .into_iter() |
| 149 | .map(|(email, owner)| (email.to_lowercase(), (owner.id, owner.username))) |
| 150 | .collect(); |
| 151 | (keys, owners) |
| 152 | } |
| 153 | |
| 154 | /// Judges changes made through g1t (not a push), records how each |
| 155 | /// ruleset judged them, and says whether they may go ahead. |
| 156 | pub(crate) async fn check_changes(&self, repo: &Repo, actor: &User, action: Action, changes: Vec<RefChange>) -> Result<Ruled> { |
| 157 | if repo.fork_of.is_some() || changes.is_empty() { |
| 158 | return Ok(Ruled::Allowed); |
| 159 | } |
| 160 | let refs: Vec<String> = changes.iter().map(|change| change.git_ref.clone()).collect(); |
| 161 | let rules = match self.ref_rules(repo, Some(actor), refs).await { |
| 162 | Ok(Some(rules)) => rules, |
| 163 | Ok(None) => return Ok(Ruled::Allowed), |
| 164 | Err(error) => { |
| 165 | worker::console_error!("ref_rules failed: {error}"); |
| 166 | return Ok(Ruled::Refused { |
| 167 | message: "The rules for this branch could not be checked just now. Try again in a moment.".to_owned(), |
| 168 | }); |
| 169 | } |
| 170 | }; |
| 171 | if rules.rulesets.is_empty() { |
| 172 | return Ok(Ruled::Allowed); |
| 173 | } |
| 174 | let facts = who(Some(actor), repo); |
| 175 | let judged: Vec<Judged> = changes |
| 176 | .iter() |
| 177 | .flat_map(|change| judge(&rules.rulesets, &rules.default_branch, facts.kind, change)) |
| 178 | .collect(); |
| 179 | let sha = changes.iter().find_map(|change| change.new.clone()); |
| 180 | self.record_judged(repo, &judged, action, &facts, sha.as_deref()).await; |
| 181 | if !outcome::refused(&judged) { |
| 182 | return Ok(Ruled::Allowed); |
| 183 | } |
| 184 | let message = report::summary(&outcome::blocking(&judged)).unwrap_or_else(|| "Rules for this branch refuse it.".to_owned()); |
| 185 | Ok(Ruled::Refused { message }) |
| 186 | } |
| 187 | |
| 188 | /// What the rules ask of a push, found before its pack is read: the |
| 189 | /// rulesets that hold for the branches and tags it changes. |
| 190 | pub(crate) async fn push_rules(&self, repo: &Repo, pusher: Option<&User>, body: &[u8]) -> PushRules { |
| 191 | if repo.fork_of.is_some() { |
| 192 | return PushRules::Nothing; |
| 193 | } |
| 194 | let updates = crate::git_http::ref_updates(body); |
| 195 | if updates.is_empty() { |
| 196 | return PushRules::Nothing; |
| 197 | } |
| 198 | let refs: Vec<String> = updates.iter().map(|(name, _, _)| name.clone()).collect(); |
| 199 | match self.ref_rules(repo, pusher, refs).await { |
| 200 | Ok(Some(rules)) if rules.rulesets.is_empty() => PushRules::Nothing, |
| 201 | Ok(Some(rules)) => PushRules::Judge { rules, updates }, |
| 202 | // Without the work service, the old protection holds. |
| 203 | Ok(None) => { |
| 204 | let protected = repo.protected.then(|| repo.default_branch.clone()); |
| 205 | PushRules::Answered( |
| 206 | protected |
| 207 | .and_then(|branch| crate::git_http::refusal(body, &branch)) |
| 208 | .map(crate::git_http::report_response) |
| 209 | .transpose(), |
| 210 | ) |
| 211 | } |
| 212 | Err(error) => { |
| 213 | worker::console_error!("ref_rules failed during a push: {error}"); |
| 214 | PushRules::Answered( |
| 215 | crate::git_http::declined( |
| 216 | body, |
| 217 | "rules could not be checked", |
| 218 | &["The rules for this repository could not be checked just now. Push again in a moment.".to_owned()], |
| 219 | ) |
| 220 | .map(Some), |
| 221 | ) |
| 222 | } |
| 223 | } |
| 224 | } |
| 225 | |
| 226 | /// Judges a push by `rules` (see [`Repos::push_rules`]). `pack` is the |
| 227 | /// push's, with its bases supplied, when it was read whole; its |
| 228 | /// commits are read only when a rule needs them. |
| 229 | #[allow(clippy::too_many_arguments)] |
| 230 | pub(crate) async fn judge_push<R: GitRepo>( |
| 231 | &self, |
| 232 | repo: &Repo, |
| 233 | pusher: Option<&User>, |
| 234 | body: &[u8], |
| 235 | rules: &RefRules, |
| 236 | updates: Vec<(String, Option<String>, Option<String>)>, |
| 237 | pack: Option<&Pack>, |
| 238 | git: &R, |
| 239 | ) -> Result<PushJudged> { |
| 240 | let facts = who(pusher, repo); |
| 241 | let content = needs_commits(&rules.rulesets, facts.kind); |
| 242 | let ancestry = needs_ancestry(&rules.rulesets); |
| 243 | // The pack, used when a rule needs what it holds. |
| 244 | let pack = pack.filter(|_| content || ancestry); |
| 245 | let signatures = rules |
| 246 | .rulesets |
| 247 | .iter() |
| 248 | .any(|ruleset| ruleset.rules.iter().any(|entry| matches!(entry.rule, Rule::RequiredSignatures(_)))); |
| 249 | // Each ref's facts are read at once. |
| 250 | let changes = futures_util::future::try_join_all(updates.into_iter().map(|(git_ref, old, new)| async move { |
| 251 | let mut change = RefChange { git_ref, old: old.clone(), new: new.clone(), fast_forward: None, commits: Vec::new(), complete: false }; |
| 252 | if let (Some(pack), Some(new)) = (pack, &new) { |
| 253 | let fast_forward = async { |
| 254 | match &old { |
| 255 | Some(old) if ancestry => Ok::<_, worker::Error>(Some(rule_facts::contains(pack, git, new, old, MAX_ANCESTRY).await?)), |
| 256 | _ => Ok(None), |
| 257 | } |
| 258 | }; |
| 259 | let commits = async { |
| 260 | if !content { |
| 261 | return Ok::<_, worker::Error>((Vec::new(), true)); |
| 262 | } |
| 263 | let Some(ids) = rule_facts::added(pack, new, MAX_COMMITS) else { |
| 264 | return Ok((Vec::new(), false)); |
| 265 | }; |
| 266 | let owners = if signatures { |
| 267 | let (fingerprints, emails) = rule_facts::signing_facts(pack, &ids); |
| 268 | Some(self.signing_owners(&fingerprints, &emails).await) |
| 269 | } else { |
| 270 | None |
| 271 | }; |
| 272 | let commits = rule_facts::read_all(pack, git, &ids, owners.as_ref()).await?; |
| 273 | let complete = commits.iter().all(|commit| commit.files_complete); |
| 274 | Ok((commits, complete)) |
| 275 | }; |
| 276 | let (fast_forward, commits) = futures_util::future::try_join(fast_forward, commits).await?; |
| 277 | change.fast_forward = fast_forward; |
| 278 | (change.commits, change.complete) = commits; |
| 279 | } else if new.is_none() || !content { |
| 280 | change.complete = true; |
| 281 | } |
| 282 | Ok::<_, worker::Error>(change) |
| 283 | })) |
| 284 | .await?; |
| 285 | let judged: Vec<Judged> = changes |
| 286 | .iter() |
| 287 | .flat_map(|change| judge(&rules.rulesets, &rules.default_branch, facts.kind, change)) |
| 288 | .collect(); |
| 289 | let sha = changes.iter().find_map(|change| change.new.clone()); |
| 290 | let refusal = if outcome::refused(&judged) { |
| 291 | let refused_ref = judged.iter().find(|one| one.blocks()).map(|one| one.git_ref.clone()).unwrap_or_default(); |
| 292 | let lines = report::remote_lines(&refused_ref, &judged, &rules_url(repo, &refused_ref)); |
| 293 | Some(crate::git_http::declined(body, &report::ng_reason(&judged), &lines)?) |
| 294 | } else { |
| 295 | None |
| 296 | }; |
| 297 | Ok(PushJudged { facts, judged, sha, refusal }) |
| 298 | } |
| 299 | |
| 300 | /// Records how the rules judged a push: before the answer when they |
| 301 | /// refused it, after it otherwise, since a push let through does not |
| 302 | /// wait on the record. |
| 303 | pub(crate) async fn record_push_judged(&self, repo: &Repo, judged: &PushJudged) { |
| 304 | if judged.refusal.is_some() { |
| 305 | self.record_judged(repo, &judged.judged, Action::Push, &judged.facts, judged.sha.as_deref()).await; |
| 306 | return; |
| 307 | } |
| 308 | let Some(work) = self.work.clone() else { return }; |
| 309 | if judged.judged.is_empty() { |
| 310 | return; |
| 311 | } |
| 312 | let evaluations = g1t_rules::evaluations(&judged.judged, &repo.id, &repo.namespace, Action::Push, &judged.facts, None, judged.sha.as_deref()); |
| 313 | self.deferred.spawn(async move { |
| 314 | let recorded: Result<u32> = g1t_kit::call(&work, "record_evaluations", &RecordEvaluationsArgs { evaluations }).await; |
| 315 | if let Err(error) = recorded { |
| 316 | worker::console_error!("rule evaluations not recorded: {error}"); |
| 317 | } |
| 318 | }); |
| 319 | } |
| 320 | |
| 321 | /// Services only: the commits a pull request would land, read as rules |
| 322 | /// look at them, fetched from its source as a pack. |
| 323 | pub(crate) async fn inspect_commits(&self, a: InspectCommitsArgs) -> Result<Outcome<InspectedCommits>> { |
| 324 | let (Some(source), Some(target)) = (self.registry.by_id(&a.source_id).await?, self.registry.by_id(&a.target_id).await?) else { |
| 325 | return Ok(Outcome::fail(FailureCode::NotFound, "Repository not found.")); |
| 326 | }; |
| 327 | self.live(&source).await?; |
| 328 | let (source_git, target_git) = (self.store.open(&store_key(&source)).await?, self.store.open(&store_key(&target)).await?); |
| 329 | let (history, base_history) = |
| 330 | futures_util::future::try_join(source_git.log(&a.head, MAX_ANCESTRY), target_git.log(&a.base_branch, MAX_ANCESTRY)).await?; |
| 331 | let shared: std::collections::HashSet<String> = base_history.into_iter().map(|commit| commit.hash).collect(); |
| 332 | let merge_base = crate::nearest_ancestor_in(&source_git, &history, &shared).await?; |
| 333 | let Some(head) = history.first() else { |
| 334 | return Ok(Outcome::Ok(InspectedCommits { commits: Vec::new(), complete: true })); |
| 335 | }; |
| 336 | let access = source_git.access(crate::store::Scope::Read).await?; |
| 337 | let fetched = crate::land::fetch_pack(&access, &head.hash, merge_base.as_deref()).await?; |
| 338 | if fetched.len() > crate::secret_scan::MAX_SCANNED_PUSH { |
| 339 | return Ok(Outcome::Ok(InspectedCommits { commits: Vec::new(), complete: false })); |
| 340 | } |
| 341 | let pack = match Pack::parse(&fetched) { |
| 342 | Ok(pack) => pack, |
| 343 | Err(problem) => { |
| 344 | worker::console_error!("a pull request's commits could not be read for rules: {problem}"); |
| 345 | return Ok(Outcome::Ok(InspectedCommits { commits: Vec::new(), complete: false })); |
| 346 | } |
| 347 | }; |
| 348 | let limit = a.limit.map_or(MAX_COMMITS, |limit| (limit as usize).min(MAX_COMMITS)); |
| 349 | let Some(ids) = rule_facts::added(&pack, &head.hash, limit) else { |
| 350 | return Ok(Outcome::Ok(InspectedCommits { commits: Vec::new(), complete: false })); |
| 351 | }; |
| 352 | let (fingerprints, emails) = rule_facts::signing_facts(&pack, &ids); |
| 353 | let owners = self.signing_owners(&fingerprints, &emails).await; |
| 354 | let commits = rule_facts::read_all(&pack, &source_git, &ids, Some(&owners)).await?; |
| 355 | let complete = commits.iter().all(|commit| commit.files_complete); |
| 356 | Ok(Outcome::Ok(InspectedCommits { commits, complete })) |
| 357 | } |
| 358 | } |
| 359 | |
| 360 | /// The facts of one commit g1t makes itself (a web edit, a catch-up |
| 361 | /// merge): it is not signed, and it changes `files`. |
| 362 | pub(crate) fn made_commit(sha: &str, message: &str, email: &str, parents: u32, files: Vec<FileChange>) -> CommitFacts { |
| 363 | CommitFacts { |
| 364 | sha: sha.to_owned(), |
| 365 | message: message.to_owned(), |
| 366 | author_email: Some(email.to_owned()), |
| 367 | committer_email: Some(email.to_owned()), |
| 368 | parents, |
| 369 | signature: g1t_contracts::rules::Signature::Unsigned, |
| 370 | files, |
| 371 | files_complete: true, |
| 372 | } |
| 373 | } |
| 374 | |
| 375 | #[cfg(test)] |
| 376 | mod tests { |
| 377 | use super::*; |
| 378 | use g1t_contracts::rules::{AppliesTo, Level, NoParameters, RefCondition, RuleEntry}; |
| 379 | |
| 380 | fn repo() -> Repo { |
| 381 | serde_json::from_value(serde_json::json!({ |
| 382 | "id": "rep_1", "namespace": "acme", "name": "web", "description": null, "isPrivate": false, |
| 383 | "ownerId": "usr_1", "defaultBranch": "main", "forkOf": null, "createdAt": "" |
| 384 | })) |
| 385 | .unwrap() |
| 386 | } |
| 387 | |
| 388 | fn ruleset(enforcement: Enforcement, rule: Rule, applies_to: AppliesTo) -> Applicable { |
| 389 | Applicable { |
| 390 | id: "rs_1".into(), |
| 391 | name: "R".into(), |
| 392 | level: Level::Repository, |
| 393 | enforcement, |
| 394 | target: Target::Branch, |
| 395 | conditions: RefCondition { include: vec!["~ALL".into()], exclude: Vec::new() }, |
| 396 | rules: vec![RuleEntry { rule, applies_to }], |
| 397 | bypass: None, |
| 398 | } |
| 399 | } |
| 400 | |
| 401 | #[test] |
| 402 | fn rules_are_linked_by_branch_or_tag() { |
| 403 | assert_eq!(rules_url(&repo(), "refs/heads/release/1"), "https://g1t.sh/acme/web/settings/rules?branch=release/1"); |
| 404 | assert_eq!(rules_url(&repo(), "refs/tags/v1"), "https://g1t.sh/acme/web/settings/rules?tag=v1"); |
| 405 | } |
| 406 | |
| 407 | #[test] |
| 408 | fn commits_are_read_only_when_a_rule_for_this_actor_needs_them() { |
| 409 | let signed = ruleset(Enforcement::Evaluate, Rule::RequiredSignatures(NoParameters {}), AppliesTo::Everyone); |
| 410 | assert!(needs_commits(&[signed], Who::Person), "evaluate-mode rulesets are recorded too"); |
| 411 | let agents = ruleset(Enforcement::Active, Rule::RequiredSignatures(NoParameters {}), AppliesTo::Agents); |
| 412 | assert!(!needs_commits(std::slice::from_ref(&agents), Who::Person)); |
| 413 | assert!(needs_commits(&[agents], Who::Agent)); |
| 414 | let off = ruleset(Enforcement::Disabled, Rule::RequiredSignatures(NoParameters {}), AppliesTo::Everyone); |
| 415 | assert!(!needs_commits(&[off], Who::Person)); |
| 416 | assert!(needs_ancestry(&[ruleset(Enforcement::Active, Rule::NonFastForward(NoParameters {}), AppliesTo::Everyone)])); |
| 417 | } |
| 418 | |
| 419 | #[test] |
| 420 | fn a_commit_g1t_makes_is_unsigned_and_lists_its_files() { |
| 421 | let made = made_commit("abc", "Add CI", "ada@acme.com", 1, vec![FileChange { path: ".g1t/workflows/ci.yml".into(), size: Some(12), deleted: false }]); |
| 422 | assert_eq!(made.signature, g1t_contracts::rules::Signature::Unsigned); |
| 423 | assert!(made.files_complete); |
| 424 | } |
| 425 | } |