Skip to content
425 linesCodeBlameRaw

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 merge1//! 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
11use std::collections::HashMap;
12
13use g1t_contracts::accounts::{EmailOwner, EmailOwnersArgs};
14use g1t_contracts::repos::Repo;
15use g1t_contracts::rules::{
16 Action, Applicable, CommitFacts, Enforcement, FileChange, InspectCommitsArgs, InspectedCommits,
17 RecordEvaluationsArgs, RefRules, RefRulesArgs, Rule, Target,
18};
19use g1t_contracts::{FailureCode, Outcome, User};
20use g1t_rules::push::{RefChange, judge};
21use 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 answer22use g1t_scan::pack::Pack;
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge23use worker::{Response, Result};
24
25use crate::registry::store_key;
26use crate::rule_facts::{self, MAX_COMMITS};
27use crate::store::{GitRepo, GitStore};
28use crate::{MAX_ANCESTRY, Repos};
29
30/// Where people read the rules of a branch.
31const 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 answer33/// What the rules ask of a push, before its pack is read.
34pub(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.
48pub(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 merge55/// What the rules said about a change.
56pub(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)]
65struct KeyOwnersArgs<'a> {
66 fingerprints: &'a [String],
67}
68
69fn 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.
78fn 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
87fn 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.
94pub(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
100impl<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 answer188 /// 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 merge191 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 answer192 return PushRules::Nothing;
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge193 }
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 answer196 return PushRules::Nothing;
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge197 }
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 answer199 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 merge202 // 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 answer205 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 merge211 }
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 answer214 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 merge222 }
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 answer224 }
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 merge240 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 answer243 // 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 merge245 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 answer249 // 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 merge251 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 answer252 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 merge262 }
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 answer263 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 merge279 } 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 answer282 Ok::<_, worker::Error>(change)
283 }))
284 .await?;
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge285 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 answer290 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 merge311 }
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 answer312 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 merge319 }
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`.
362pub(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)]
376mod 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.