Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Merge rulesets: branch and tag rules, agent-first, enforced on push and merge | 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 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.