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}; | |
| A push is checked once and side by side: its pack is read and its bases fetched once for the rules, the workflow gate and the secret scan, which run together, other services are asked while the pack is read, and cache writes and rule records finish after git has its answer | 22 | use g1t_scan::pack::Pack; |
| Merge rulesets: branch and tag rules, agent-first, enforced on push and merge | 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 | ||
| A push is checked once and side by side: its pack is read and its bases fetched once for the rules, the workflow gate and the secret scan, which run together, other services are asked while the pack is read, and cache writes and rule records finish after git has its answer | 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 | ||
| Merge rulesets: branch and tag rules, agent-first, enforced on push and merge | 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 | ||
| A push is checked once and side by side: its pack is read and its bases fetched once for the rules, the workflow gate and the secret scan, which run together, other services are asked while the pack is read, and cache writes and rule records finish after git has its answer | 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 { | |
| Merge rulesets: branch and tag rules, agent-first, enforced on push and merge | 191 | if repo.fork_of.is_some() { |
| A push is checked once and side by side: its pack is read and its bases fetched once for the rules, the workflow gate and the secret scan, which run together, other services are asked while the pack is read, and cache writes and rule records finish after git has its answer | 192 | return PushRules::Nothing; |
| Merge rulesets: branch and tag rules, agent-first, enforced on push and merge | 193 | } |
| 194 | let updates = crate::git_http::ref_updates(body); | |
| 195 | if updates.is_empty() { | |
| A push is checked once and side by side: its pack is read and its bases fetched once for the rules, the workflow gate and the secret scan, which run together, other services are asked while the pack is read, and cache writes and rule records finish after git has its answer | 196 | return PushRules::Nothing; |
| Merge rulesets: branch and tag rules, agent-first, enforced on push and merge | 197 | } |
| 198 | let refs: Vec<String> = updates.iter().map(|(name, _, _)| name.clone()).collect(); | |
| A push is checked once and side by side: its pack is read and its bases fetched once for the rules, the workflow gate and the secret scan, which run together, other services are asked while the pack is read, and cache writes and rule records finish after git has its answer | 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 }, | |
| Merge rulesets: branch and tag rules, agent-first, enforced on push and merge | 202 | // Without the work service, the old protection holds. |
| 203 | Ok(None) => { | |
| 204 | let protected = repo.protected.then(|| repo.default_branch.clone()); | |
| A push is checked once and side by side: its pack is read and its bases fetched once for the rules, the workflow gate and the secret scan, which run together, other services are asked while the pack is read, and cache writes and rule records finish after git has its answer | 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 | ) | |
| Merge rulesets: branch and tag rules, agent-first, enforced on push and merge | 211 | } |
| 212 | Err(error) => { | |
| 213 | worker::console_error!("ref_rules failed during a push: {error}"); | |
| A push is checked once and side by side: its pack is read and its bases fetched once for the rules, the workflow gate and the secret scan, which run together, other services are asked while the pack is read, and cache writes and rule records finish after git has its answer | 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 | ) | |
| Merge rulesets: branch and tag rules, agent-first, enforced on push and merge | 222 | } |
| 223 | } | |
| A push is checked once and side by side: its pack is read and its bases fetched once for the rules, the workflow gate and the secret scan, which run together, other services are asked while the pack is read, and cache writes and rule records finish after git has its answer | 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> { | |
| Merge rulesets: branch and tag rules, agent-first, enforced on push and merge | 240 | let facts = who(pusher, repo); |
| 241 | let content = needs_commits(&rules.rulesets, facts.kind); | |
| 242 | let ancestry = needs_ancestry(&rules.rulesets); | |
| A push is checked once and side by side: its pack is read and its bases fetched once for the rules, the workflow gate and the secret scan, which run together, other services are asked while the pack is read, and cache writes and rule records finish after git has its answer | 243 | // The pack, used when a rule needs what it holds. |
| 244 | let pack = pack.filter(|_| content || ancestry); | |
| Merge rulesets: branch and tag rules, agent-first, enforced on push and merge | 245 | let signatures = rules |
| 246 | .rulesets | |
| 247 | .iter() | |
| 248 | .any(|ruleset| ruleset.rules.iter().any(|entry| matches!(entry.rule, Rule::RequiredSignatures(_)))); | |
| A push is checked once and side by side: its pack is read and its bases fetched once for the rules, the workflow gate and the secret scan, which run together, other services are asked while the pack is read, and cache writes and rule records finish after git has its answer | 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 { | |
| Merge rulesets: branch and tag rules, agent-first, enforced on push and merge | 251 | let mut change = RefChange { git_ref, old: old.clone(), new: new.clone(), fast_forward: None, commits: Vec::new(), complete: false }; |
| A push is checked once and side by side: its pack is read and its bases fetched once for the rules, the workflow gate and the secret scan, which run together, other services are asked while the pack is read, and cache writes and rule records finish after git has its answer | 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)); | |
| Merge rulesets: branch and tag rules, agent-first, enforced on push and merge | 262 | } |
| A push is checked once and side by side: its pack is read and its bases fetched once for the rules, the workflow gate and the secret scan, which run together, other services are asked while the pack is read, and cache writes and rule records finish after git has its answer | 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; | |
| Merge rulesets: branch and tag rules, agent-first, enforced on push and merge | 279 | } else if new.is_none() || !content { |
| 280 | change.complete = true; | |
| 281 | } | |
| A push is checked once and side by side: its pack is read and its bases fetched once for the rules, the workflow gate and the secret scan, which run together, other services are asked while the pack is read, and cache writes and rule records finish after git has its answer | 282 | Ok::<_, worker::Error>(change) |
| 283 | })) | |
| 284 | .await?; | |
| Merge rulesets: branch and tag rules, agent-first, enforced on push and merge | 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()); | |
| A push is checked once and side by side: its pack is read and its bases fetched once for the rules, the workflow gate and the secret scan, which run together, other services are asked while the pack is read, and cache writes and rule records finish after git has its answer | 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; | |
| Merge rulesets: branch and tag rules, agent-first, enforced on push and merge | 311 | } |
| A push is checked once and side by side: its pack is read and its bases fetched once for the rules, the workflow gate and the secret scan, which run together, other services are asked while the pack is read, and cache writes and rule records finish after git has its answer | 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 | }); | |
| Merge rulesets: branch and tag rules, agent-first, enforced on push and merge | 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 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.