Skip to content

g1t/services/repos/src/rules.rs

375 lines17,709 bytesCodeBlame
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, pack_start};
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 said about a change.
34pub(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)]
43struct KeyOwnersArgs<'a> {
44 fingerprints: &'a [String],
45}
46
47fn 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.
56fn 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
65fn 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.
72pub(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
78impl<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`.
312pub(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)]
326mod 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}