Skip to content
425 linesCodeBlameRaw
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
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};
22use g1t_scan::pack::Pack;
23use 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
33/// 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
55/// 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
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`.
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}