Skip to content
380 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, 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 // Workflow files need their own scope from a token, on a pull
171 // request's working copy too (workflow_gate.rs).
172 if let Some(response) = self.workflow_gate(repo, pusher, body, whole).await? {
173 return Ok(Some(response));
174 }
175 if repo.fork_of.is_some() {
176 return Ok(None);
177 }
178 let updates = crate::git_http::ref_updates(body);
179 if updates.is_empty() {
180 return Ok(None);
181 }
182 let refs: Vec<String> = updates.iter().map(|(name, _, _)| name.clone()).collect();
183 let rules = match self.ref_rules(repo, pusher, refs).await {
184 Ok(Some(rules)) => rules,
185 // Without the work service, the old protection holds.
186 Ok(None) => {
187 let protected = repo.protected.then(|| repo.default_branch.clone());
188 return protected
189 .and_then(|branch| crate::git_http::refusal(body, &branch))
190 .map(crate::git_http::report_response)
191 .transpose();
192 }
193 Err(error) => {
194 worker::console_error!("ref_rules failed during a push: {error}");
195 return Ok(Some(crate::git_http::declined(
196 body,
197 "rules could not be checked",
198 &["The rules for this repository could not be checked just now. Push again in a moment.".to_owned()],
199 )?));
200 }
201 };
202 if rules.rulesets.is_empty() {
203 return Ok(None);
204 }
205 let facts = who(pusher, repo);
206 let content = needs_commits(&rules.rulesets, facts.kind);
207 let ancestry = needs_ancestry(&rules.rulesets);
208 let git = self.store.open(&store_key(repo)).await?;
209 // The pack, read when a rule needs what it holds.
210 let pack = if whole && (content || ancestry) {
211 match pack_start(body).map(|start| Pack::parse(&body[start..])) {
212 Some(Ok(mut pack)) => {
213 crate::secret_scan::supply_bases(&mut pack, &git).await?;
214 Some(pack)
215 }
216 Some(Err(problem)) => {
217 worker::console_error!("a push's pack could not be read for rules: {problem}");
218 None
219 }
220 // Nothing but deletions, or pointing refs at commits the
221 // repository has: an empty pack.
222 None => Pack::parse(crate::land::EMPTY_PACK).ok(),
223 }
224 } else {
225 None
226 };
227 let signatures = rules
228 .rulesets
229 .iter()
230 .any(|ruleset| ruleset.rules.iter().any(|entry| matches!(entry.rule, Rule::RequiredSignatures(_))));
231 let mut changes = Vec::new();
232 for (git_ref, old, new) in updates {
233 let mut change = RefChange { git_ref, old: old.clone(), new: new.clone(), fast_forward: None, commits: Vec::new(), complete: false };
234 if let (Some(pack), Some(new)) = (&pack, &new) {
235 if let Some(old) = &old
236 && ancestry
237 {
238 change.fast_forward = Some(rule_facts::contains(pack, &git, new, old, MAX_ANCESTRY).await?);
239 }
240 if content {
241 match rule_facts::added(pack, new, MAX_COMMITS) {
242 Some(ids) => {
243 let owners = if signatures {
244 let (fingerprints, emails) = rule_facts::signing_facts(pack, &ids);
245 Some(self.signing_owners(&fingerprints, &emails).await)
246 } else {
247 None
248 };
249 change.commits = rule_facts::read_all(pack, &git, &ids, owners.as_ref()).await?;
250 change.complete = change.commits.iter().all(|commit| commit.files_complete);
251 }
252 None => change.complete = false,
253 }
254 } else {
255 change.complete = true;
256 }
257 } else if new.is_none() || !content {
258 change.complete = true;
259 }
260 changes.push(change);
261 }
262 let judged: Vec<Judged> = changes
263 .iter()
264 .flat_map(|change| judge(&rules.rulesets, &rules.default_branch, facts.kind, change))
265 .collect();
266 let sha = changes.iter().find_map(|change| change.new.clone());
267 self.record_judged(repo, &judged, Action::Push, &facts, sha.as_deref()).await;
268 if !outcome::refused(&judged) {
269 return Ok(None);
270 }
271 let refused_ref = judged.iter().find(|one| one.blocks()).map(|one| one.git_ref.clone()).unwrap_or_default();
272 let lines = report::remote_lines(&refused_ref, &judged, &rules_url(repo, &refused_ref));
273 Ok(Some(crate::git_http::declined(body, &report::ng_reason(&judged), &lines)?))
274 }
275
276 /// Services only: the commits a pull request would land, read as rules
277 /// look at them, fetched from its source as a pack.
278 pub(crate) async fn inspect_commits(&self, a: InspectCommitsArgs) -> Result<Outcome<InspectedCommits>> {
279 let (Some(source), Some(target)) = (self.registry.by_id(&a.source_id).await?, self.registry.by_id(&a.target_id).await?) else {
280 return Ok(Outcome::fail(FailureCode::NotFound, "Repository not found."));
281 };
282 self.live(&source).await?;
283 let (source_git, target_git) = (self.store.open(&store_key(&source)).await?, self.store.open(&store_key(&target)).await?);
284 let (history, base_history) =
285 futures_util::future::try_join(source_git.log(&a.head, MAX_ANCESTRY), target_git.log(&a.base_branch, MAX_ANCESTRY)).await?;
286 let shared: std::collections::HashSet<String> = base_history.into_iter().map(|commit| commit.hash).collect();
287 let merge_base = crate::nearest_ancestor_in(&source_git, &history, &shared).await?;
288 let Some(head) = history.first() else {
289 return Ok(Outcome::Ok(InspectedCommits { commits: Vec::new(), complete: true }));
290 };
291 let access = source_git.access(crate::store::Scope::Read).await?;
292 let fetched = crate::land::fetch_pack(&access, &head.hash, merge_base.as_deref()).await?;
293 if fetched.len() > crate::secret_scan::MAX_SCANNED_PUSH {
294 return Ok(Outcome::Ok(InspectedCommits { commits: Vec::new(), complete: false }));
295 }
296 let pack = match Pack::parse(&fetched) {
297 Ok(pack) => pack,
298 Err(problem) => {
299 worker::console_error!("a pull request's commits could not be read for rules: {problem}");
300 return Ok(Outcome::Ok(InspectedCommits { commits: Vec::new(), complete: false }));
301 }
302 };
303 let limit = a.limit.map_or(MAX_COMMITS, |limit| (limit as usize).min(MAX_COMMITS));
304 let Some(ids) = rule_facts::added(&pack, &head.hash, limit) else {
305 return Ok(Outcome::Ok(InspectedCommits { commits: Vec::new(), complete: false }));
306 };
307 let (fingerprints, emails) = rule_facts::signing_facts(&pack, &ids);
308 let owners = self.signing_owners(&fingerprints, &emails).await;
309 let commits = rule_facts::read_all(&pack, &source_git, &ids, Some(&owners)).await?;
310 let complete = commits.iter().all(|commit| commit.files_complete);
311 Ok(Outcome::Ok(InspectedCommits { commits, complete }))
312 }
313}
314
315/// The facts of one commit g1t makes itself (a web edit, a catch-up
316/// merge): it is not signed, and it changes `files`.
317pub(crate) fn made_commit(sha: &str, message: &str, email: &str, parents: u32, files: Vec<FileChange>) -> CommitFacts {
318 CommitFacts {
319 sha: sha.to_owned(),
320 message: message.to_owned(),
321 author_email: Some(email.to_owned()),
322 committer_email: Some(email.to_owned()),
323 parents,
324 signature: g1t_contracts::rules::Signature::Unsigned,
325 files,
326 files_complete: true,
327 }
328}
329
330#[cfg(test)]
331mod tests {
332 use super::*;
333 use g1t_contracts::rules::{AppliesTo, Level, NoParameters, RefCondition, RuleEntry};
334
335 fn repo() -> Repo {
336 serde_json::from_value(serde_json::json!({
337 "id": "rep_1", "namespace": "acme", "name": "web", "description": null, "isPrivate": false,
338 "ownerId": "usr_1", "defaultBranch": "main", "forkOf": null, "createdAt": ""
339 }))
340 .unwrap()
341 }
342
343 fn ruleset(enforcement: Enforcement, rule: Rule, applies_to: AppliesTo) -> Applicable {
344 Applicable {
345 id: "rs_1".into(),
346 name: "R".into(),
347 level: Level::Repository,
348 enforcement,
349 target: Target::Branch,
350 conditions: RefCondition { include: vec!["~ALL".into()], exclude: Vec::new() },
351 rules: vec![RuleEntry { rule, applies_to }],
352 bypass: None,
353 }
354 }
355
356 #[test]
357 fn rules_are_linked_by_branch_or_tag() {
358 assert_eq!(rules_url(&repo(), "refs/heads/release/1"), "https://g1t.sh/acme/web/settings/rules?branch=release/1");
359 assert_eq!(rules_url(&repo(), "refs/tags/v1"), "https://g1t.sh/acme/web/settings/rules?tag=v1");
360 }
361
362 #[test]
363 fn commits_are_read_only_when_a_rule_for_this_actor_needs_them() {
364 let signed = ruleset(Enforcement::Evaluate, Rule::RequiredSignatures(NoParameters {}), AppliesTo::Everyone);
365 assert!(needs_commits(&[signed], Who::Person), "evaluate-mode rulesets are recorded too");
366 let agents = ruleset(Enforcement::Active, Rule::RequiredSignatures(NoParameters {}), AppliesTo::Agents);
367 assert!(!needs_commits(std::slice::from_ref(&agents), Who::Person));
368 assert!(needs_commits(&[agents], Who::Agent));
369 let off = ruleset(Enforcement::Disabled, Rule::RequiredSignatures(NoParameters {}), AppliesTo::Everyone);
370 assert!(!needs_commits(&[off], Who::Person));
371 assert!(needs_ancestry(&[ruleset(Enforcement::Active, Rule::NonFastForward(NoParameters {}), AppliesTo::Everyone)]));
372 }
373
374 #[test]
375 fn a_commit_g1t_makes_is_unsigned_and_lists_its_files() {
376 let made = made_commit("abc", "Add CI", "ada@acme.com", 1, vec![FileChange { path: ".g1t/workflows/ci.yml".into(), size: Some(12), deleted: false }]);
377 assert_eq!(made.signature, g1t_contracts::rules::Signature::Unsigned);
378 assert!(made.files_complete);
379 }
380}