Skip to content

g1t/crates/rules/src/select.rs

476 lines20,940 bytesCodeBlame
1//! Which rulesets hold where, who may bypass them, and what holds for one
2//! branch once they are stacked.
3
4use g1t_contracts::access::{self, RepoRole};
5use g1t_contracts::repos::Repo;
6use g1t_contracts::rules::{
7 ALL, ActorKind, Applicable, BypassActor, BypassMode, DEFAULT_BRANCH, EffectiveRule, EffectiveRules, Enforcement,
8 Level, RefCondition, RepositoryCondition, Ruleset, RulesetSummary, Target, VisibilityCondition,
9};
10use g1t_contracts::{PrincipalKind, Role, User};
11
12use crate::glob;
13
14/// What a ruleset needs to know about a repository to say whether it holds.
15#[derive(Clone, Copy, Debug)]
16pub struct RepoFacts<'a> {
17 pub id: &'a str,
18 pub name: &'a str,
19 pub private: bool,
20 pub topics: &'a [String],
21 pub default_branch: &'a str,
22}
23
24impl<'a> From<&'a Repo> for RepoFacts<'a> {
25 fn from(repo: &'a Repo) -> Self {
26 RepoFacts {
27 id: &repo.id,
28 name: &repo.name,
29 private: repo.is_private,
30 topics: &repo.topics,
31 default_branch: &repo.default_branch,
32 }
33 }
34}
35
36/// A name written as a full ref, shortened: `refs/heads/main` is `main`.
37fn short(pattern: &str, target: Target) -> &str {
38 let prefix = match target {
39 Target::Branch => "refs/heads/",
40 Target::Tag => "refs/tags/",
41 };
42 pattern.strip_prefix(prefix).unwrap_or(pattern)
43}
44
45fn ref_pattern_matches(pattern: &str, target: Target, name: &str, default_branch: &str) -> bool {
46 let pattern = pattern.trim();
47 match pattern {
48 ALL => true,
49 DEFAULT_BRANCH => target == Target::Branch && name == default_branch,
50 _ => glob::matches(short(pattern, target), name),
51 }
52}
53
54/// Whether a branch or tag named `name` is one `condition` selects.
55pub fn ref_matches(condition: &RefCondition, target: Target, name: &str, default_branch: &str) -> bool {
56 condition.include.iter().any(|pattern| ref_pattern_matches(pattern, target, name, default_branch))
57 && !condition.exclude.iter().any(|pattern| ref_pattern_matches(pattern, target, name, default_branch))
58}
59
60/// Whether a workspace ruleset's repository condition selects `repo`.
61pub fn repo_matches(condition: &RepositoryCondition, repo: RepoFacts<'_>) -> bool {
62 let name = repo.name.to_lowercase();
63 let named = |pattern: &String| {
64 let pattern = pattern.trim();
65 pattern == ALL || glob::matches(&pattern.to_lowercase(), &name)
66 };
67 let visible = match condition.visibility {
68 VisibilityCondition::Any => true,
69 VisibilityCondition::Public => !repo.private,
70 VisibilityCondition::Private => repo.private,
71 };
72 let topical = condition.topics.is_empty()
73 || condition
74 .topics
75 .iter()
76 .any(|topic| repo.topics.iter().any(|has| has.eq_ignore_ascii_case(topic.trim())));
77 condition.include.iter().any(named) && !condition.exclude.iter().any(named) && visible && topical
78}
79
80/// Whether a ruleset holds in `repo` at all, whatever the branch: a
81/// repository's own, or a workspace's that selects it. Disabled ones never.
82pub fn holds_in(ruleset: &Ruleset, repo: RepoFacts<'_>) -> bool {
83 if ruleset.spec.enforcement == Enforcement::Disabled {
84 return false;
85 }
86 match ruleset.level {
87 Level::Repository => ruleset.repo_id.as_deref() == Some(repo.id),
88 Level::Workspace => {
89 let condition = ruleset.spec.conditions.repository.clone().unwrap_or_default();
90 repo_matches(&condition, repo)
91 }
92 }
93}
94
95/// Who is changing something, as bypass lists name people.
96#[derive(Clone, Debug, Default, PartialEq, Eq)]
97pub struct ActorFacts {
98 pub username: String,
99 pub kind: Who,
100 /// Their role on the repository.
101 pub role: Option<RepoRole>,
102 /// Whether they own its workspace.
103 pub owner: bool,
104 /// The teams they are in, as `workspace/slug`, lowercase.
105 pub teams: Vec<String>,
106 /// The token they act through, if any.
107 pub token_id: Option<String>,
108}
109
110/// What kind of actor.
111#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
112pub enum Who {
113 #[default]
114 Person,
115 /// An agent acting through a token, g1t's or another.
116 Agent,
117 /// g1t acting on its own: the merge queue, security updates.
118 G1t,
119 /// A workspace's own token.
120 Token,
121}
122
123impl Who {
124 pub fn as_str(self) -> &'static str {
125 match self {
126 Who::Person => "person",
127 Who::Agent => "agent",
128 Who::G1t => "g1t",
129 Who::Token => "token",
130 }
131 }
132
133 /// Rules for agents hold for agents and g1t; the rest are people's.
134 pub fn is_agent(self) -> bool {
135 matches!(self, Who::Agent | Who::G1t)
136 }
137}
138
139impl ActorFacts {
140 /// What `user` is in `repo`. Their teams are the caller's to add.
141 pub fn of(user: &User, repo: &Repo) -> ActorFacts {
142 let kind = match user.kind {
143 PrincipalKind::System => Who::G1t,
144 PrincipalKind::Agent => Who::Agent,
145 _ if user.acting.is_some() => Who::Agent,
146 PrincipalKind::Workspace => Who::Token,
147 PrincipalKind::User => Who::Person,
148 };
149 let token_id = user
150 .acting
151 .as_ref()
152 .map(|acting| acting.credential_id.clone())
153 .or_else(|| user.token.as_ref().map(|token| token.token_id.clone()))
154 .filter(|id| !id.is_empty());
155 ActorFacts {
156 username: user.username.clone(),
157 kind,
158 role: access::permission(Some(user), repo),
159 owner: user.role_in(&repo.namespace.to_lowercase()) == Some(Role::Owner),
160 teams: Vec::new(),
161 token_id,
162 }
163 }
164}
165
166/// A team named in a bypass list or a rule, as `workspace/slug`.
167pub fn team_key(name: &str, workspace: &str) -> String {
168 let name = name.trim().trim_start_matches('@').to_lowercase();
169 if name.contains('/') { name } else { format!("{}/{name}", workspace.to_lowercase()) }
170}
171
172/// Whether one bypass entry names the actor. An agent or a token is never
173/// named by a role, a team or a person: only by `g1t` or its token, so an
174/// agent acting for an admin obeys the rules its admin may bypass.
175fn names(entry: &BypassActor, who: &ActorFacts, workspace: &str) -> bool {
176 let value = entry.value.trim();
177 match entry.kind {
178 // g1t's agents act through run tokens (`Agent`), and g1t on its own
179 // as `System`.
180 ActorKind::G1t => who.kind.is_agent(),
181 ActorKind::Token => {
182 (value.eq_ignore_ascii_case("workspace") && who.kind == Who::Token)
183 || who.token_id.as_deref().is_some_and(|id| !value.is_empty() && id == value)
184 }
185 _ if who.kind != Who::Person => false,
186 ActorKind::User => !value.is_empty() && who.username.eq_ignore_ascii_case(value.trim_start_matches('@')),
187 ActorKind::Team => !value.is_empty() && who.teams.contains(&team_key(value, workspace)),
188 ActorKind::Role => match value.to_ascii_lowercase().as_str() {
189 "owner" => who.owner,
190 role => RepoRole::parse(role).is_some_and(|wanted| who.role.is_some_and(|has| has >= wanted)),
191 },
192 }
193}
194
195/// How the actor may bypass a ruleset with these bypass actors, if at all.
196/// `always` wins over `pull_requests` when both name them.
197pub fn bypass(actors: &[BypassActor], who: &ActorFacts, workspace: &str) -> Option<BypassMode> {
198 let modes: Vec<BypassMode> = actors.iter().filter(|entry| names(entry, who, workspace)).map(|entry| entry.mode).collect();
199 if modes.contains(&BypassMode::Always) {
200 Some(BypassMode::Always)
201 } else {
202 modes.first().copied()
203 }
204}
205
206/// Whether any bypass list names a team: only then are the actor's teams
207/// worth looking up.
208pub fn names_teams(rulesets: &[Ruleset]) -> bool {
209 rulesets
210 .iter()
211 .any(|ruleset| ruleset.spec.bypass_actors.iter().any(|entry| entry.kind == ActorKind::Team))
212}
213
214/// The rulesets that hold in `repo` for any of `refs` (full refs), as a
215/// service applies them, with how the actor may bypass each.
216pub fn applicable(rulesets: &[Ruleset], repo: RepoFacts<'_>, refs: &[String], who: Option<&ActorFacts>, workspace: &str) -> Vec<Applicable> {
217 rulesets
218 .iter()
219 .filter(|ruleset| holds_in(ruleset, repo))
220 .filter(|ruleset| {
221 refs.iter().any(|git_ref| match Target::of_ref(git_ref) {
222 Some((target, name)) => {
223 target == ruleset.spec.target
224 && ref_matches(&ruleset.spec.conditions.ref_name, target, name, repo.default_branch)
225 }
226 None => false,
227 })
228 })
229 .map(|ruleset| Applicable {
230 id: ruleset.id.clone(),
231 name: ruleset.spec.name.clone(),
232 level: ruleset.level,
233 enforcement: ruleset.spec.enforcement,
234 target: ruleset.spec.target,
235 conditions: ruleset.spec.conditions.ref_name.clone(),
236 rules: ruleset.spec.rules.clone(),
237 bypass: who.and_then(|who| bypass(&ruleset.spec.bypass_actors, who, workspace)),
238 })
239 .collect()
240}
241
242/// Whether an applicable ruleset holds for a full ref.
243pub fn applies_to_ref(ruleset: &Applicable, git_ref: &str, default_branch: &str) -> bool {
244 match Target::of_ref(git_ref) {
245 Some((target, name)) => target == ruleset.target && ref_matches(&ruleset.conditions, target, name, default_branch),
246 None => false,
247 }
248}
249
250/// Every rule that holds for one branch or tag: active rulesets' first,
251/// then those being evaluated, each with where it comes from.
252pub fn effective(rulesets: &[Ruleset], repo: RepoFacts<'_>, target: Target, name: &str) -> EffectiveRules {
253 let mut holding: Vec<&Ruleset> = rulesets
254 .iter()
255 .filter(|ruleset| holds_in(ruleset, repo))
256 .filter(|ruleset| ruleset.spec.target == target)
257 .filter(|ruleset| ref_matches(&ruleset.spec.conditions.ref_name, target, name, repo.default_branch))
258 .collect();
259 // Active before evaluate; workspace before repository; then by name.
260 holding.sort_by_key(|ruleset| {
261 (
262 ruleset.spec.enforcement != Enforcement::Active,
263 ruleset.level != Level::Workspace,
264 ruleset.spec.name.to_lowercase(),
265 )
266 });
267 EffectiveRules {
268 name: name.to_owned(),
269 target,
270 default_branch: target == Target::Branch && name == repo.default_branch,
271 rules: holding
272 .iter()
273 .flat_map(|ruleset| {
274 ruleset.spec.rules.iter().map(|entry| EffectiveRule {
275 entry: entry.clone(),
276 ruleset_id: ruleset.id.clone(),
277 ruleset_name: ruleset.spec.name.clone(),
278 level: ruleset.level,
279 enforcement: ruleset.spec.enforcement,
280 })
281 })
282 .collect(),
283 rulesets: holding
284 .iter()
285 .map(|ruleset| RulesetSummary {
286 id: ruleset.id.clone(),
287 name: ruleset.spec.name.clone(),
288 level: ruleset.level,
289 enforcement: ruleset.spec.enforcement,
290 bypass_actors: ruleset.spec.bypass_actors.clone(),
291 })
292 .collect(),
293 }
294}
295
296#[cfg(test)]
297mod tests {
298 use super::*;
299 use g1t_contracts::rules::{BypassMode, Conditions, NoParameters, Rule, RuleEntry, RulesetSpec};
300
301 pub(crate) fn ruleset(id: &str, level: Level, include: &[&str], exclude: &[&str], rules: Vec<Rule>) -> Ruleset {
302 Ruleset {
303 id: id.into(),
304 level,
305 workspace: "acme".into(),
306 repo_id: (level == Level::Repository).then(|| "rep_1".to_owned()),
307 repository: None,
308 spec: RulesetSpec {
309 name: id.into(),
310 conditions: Conditions {
311 ref_name: RefCondition {
312 include: include.iter().map(|p| (*p).to_owned()).collect(),
313 exclude: exclude.iter().map(|p| (*p).to_owned()).collect(),
314 },
315 repository: None,
316 },
317 rules: rules.into_iter().map(RuleEntry::everyone).collect(),
318 ..RulesetSpec::default()
319 },
320 source: None,
321 created_by: "ada".into(),
322 created_at: String::new(),
323 updated_by: "ada".into(),
324 updated_at: String::new(),
325 }
326 }
327
328 fn repo<'a>(topics: &'a [String]) -> RepoFacts<'a> {
329 RepoFacts { id: "rep_1", name: "web", private: true, topics, default_branch: "main" }
330 }
331
332 #[test]
333 fn names_match_patterns_the_default_branch_and_all() {
334 let condition = |include: &[&str], exclude: &[&str]| RefCondition {
335 include: include.iter().map(|p| (*p).to_owned()).collect(),
336 exclude: exclude.iter().map(|p| (*p).to_owned()).collect(),
337 };
338 assert!(ref_matches(&condition(&["~DEFAULT_BRANCH"], &[]), Target::Branch, "main", "main"));
339 assert!(!ref_matches(&condition(&["~DEFAULT_BRANCH"], &[]), Target::Branch, "dev", "main"));
340 assert!(!ref_matches(&condition(&["~DEFAULT_BRANCH"], &[]), Target::Tag, "main", "main"));
341 assert!(ref_matches(&condition(&["~ALL"], &["dependabot/**"]), Target::Branch, "feature/x", "main"));
342 assert!(!ref_matches(&condition(&["~ALL"], &["g1t-queue/**"]), Target::Branch, "g1t-queue/a", "main"));
343 assert!(ref_matches(&condition(&["refs/heads/release/*"], &[]), Target::Branch, "release/2", "main"));
344 assert!(ref_matches(&condition(&["v*"], &[]), Target::Tag, "v1.0.0", "main"));
345 assert!(!ref_matches(&condition(&[], &[]), Target::Branch, "main", "main"), "an empty include selects nothing");
346 }
347
348 #[test]
349 fn workspace_rulesets_select_repositories_by_name_visibility_and_topic() {
350 let topics = vec!["payments".to_owned()];
351 let mut condition = RepositoryCondition::default();
352 assert!(repo_matches(&condition, repo(&topics)));
353 condition.include = vec!["api-*".into()];
354 assert!(!repo_matches(&condition, repo(&topics)));
355 condition.include = vec!["W*".into()];
356 assert!(repo_matches(&condition, repo(&topics)), "names ignore case");
357 condition.exclude = vec!["web".into()];
358 assert!(!repo_matches(&condition, repo(&topics)));
359 condition.exclude.clear();
360 condition.visibility = VisibilityCondition::Public;
361 assert!(!repo_matches(&condition, repo(&topics)));
362 condition.visibility = VisibilityCondition::Private;
363 condition.topics = vec!["Payments".into()];
364 assert!(repo_matches(&condition, repo(&topics)));
365 condition.topics = vec!["docs".into()];
366 assert!(!repo_matches(&condition, repo(&topics)));
367 }
368
369 #[test]
370 fn a_repository_ruleset_holds_only_in_its_repository_and_disabled_ones_nowhere() {
371 let topics = Vec::new();
372 let own = ruleset("a", Level::Repository, &["~ALL"], &[], vec![]);
373 assert!(holds_in(&own, repo(&topics)));
374 let other = Ruleset { repo_id: Some("rep_2".into()), ..own.clone() };
375 assert!(!holds_in(&other, repo(&topics)));
376 let mut off = own.clone();
377 off.spec.enforcement = Enforcement::Disabled;
378 assert!(!holds_in(&off, repo(&topics)));
379 }
380
381 fn person(role: RepoRole) -> ActorFacts {
382 ActorFacts { username: "ada".into(), kind: Who::Person, role: Some(role), ..ActorFacts::default() }
383 }
384
385 fn entry(kind: ActorKind, value: &str, mode: BypassMode) -> BypassActor {
386 BypassActor { kind, value: value.into(), mode }
387 }
388
389 #[test]
390 fn nobody_bypasses_by_default() {
391 assert_eq!(bypass(&[], &person(RepoRole::Admin), "acme"), None);
392 let g1t = ActorFacts { kind: Who::G1t, username: "g1t".into(), ..ActorFacts::default() };
393 assert_eq!(bypass(&[], &g1t, "acme"), None);
394 }
395
396 #[test]
397 fn roles_people_and_teams_bypass_as_listed() {
398 let list = [entry(ActorKind::Role, "maintain", BypassMode::PullRequests)];
399 assert_eq!(bypass(&list, &person(RepoRole::Admin), "acme"), Some(BypassMode::PullRequests));
400 assert_eq!(bypass(&list, &person(RepoRole::Write), "acme"), None);
401 let both = [
402 entry(ActorKind::Role, "write", BypassMode::PullRequests),
403 entry(ActorKind::User, "@Ada", BypassMode::Always),
404 ];
405 assert_eq!(bypass(&both, &person(RepoRole::Write), "acme"), Some(BypassMode::Always));
406 let team = [entry(ActorKind::Team, "release", BypassMode::Always)];
407 let mut ada = person(RepoRole::Read);
408 assert_eq!(bypass(&team, &ada, "acme"), None);
409 ada.teams.push("acme/release".into());
410 assert_eq!(bypass(&team, &ada, "acme"), Some(BypassMode::Always));
411 let owners = [entry(ActorKind::Role, "owner", BypassMode::Always)];
412 assert_eq!(bypass(&owners, &ada, "acme"), None);
413 ada.owner = true;
414 assert_eq!(bypass(&owners, &ada, "acme"), Some(BypassMode::Always));
415 }
416
417 #[test]
418 fn agents_bypass_only_when_g1t_or_their_token_is_listed() {
419 let agent = ActorFacts {
420 username: "g1t".into(),
421 kind: Who::Agent,
422 role: Some(RepoRole::Admin),
423 owner: true,
424 token_id: Some("tok_1".into()),
425 ..ActorFacts::default()
426 };
427 let admins = [entry(ActorKind::Role, "admin", BypassMode::Always), entry(ActorKind::Role, "owner", BypassMode::Always)];
428 assert_eq!(bypass(&admins, &agent, "acme"), None, "an agent does not take its person's role");
429 assert_eq!(bypass(&[entry(ActorKind::G1t, "", BypassMode::Always)], &agent, "acme"), Some(BypassMode::Always));
430 assert_eq!(bypass(&[entry(ActorKind::Token, "tok_1", BypassMode::Always)], &agent, "acme"), Some(BypassMode::Always));
431 assert_eq!(bypass(&[entry(ActorKind::Token, "tok_2", BypassMode::Always)], &agent, "acme"), None);
432 let system = ActorFacts { kind: Who::G1t, ..ActorFacts::default() };
433 assert_eq!(bypass(&[entry(ActorKind::G1t, "", BypassMode::PullRequests)], &system, "acme"), Some(BypassMode::PullRequests));
434 let token = ActorFacts { kind: Who::Token, ..ActorFacts::default() };
435 assert_eq!(bypass(&[entry(ActorKind::Token, "workspace", BypassMode::Always)], &token, "acme"), Some(BypassMode::Always));
436 }
437
438 #[test]
439 fn effective_rules_stack_every_ruleset_that_holds() {
440 let topics = Vec::new();
441 let mut evaluate = ruleset("b-dry", Level::Repository, &["main"], &[], vec![Rule::RequiredLinearHistory(NoParameters {})]);
442 evaluate.spec.enforcement = Enforcement::Evaluate;
443 let rulesets = vec![
444 evaluate,
445 ruleset("a-main", Level::Repository, &["~DEFAULT_BRANCH"], &[], vec![Rule::Deletion(NoParameters {}), Rule::NonFastForward(NoParameters {})]),
446 ruleset("org", Level::Workspace, &["~ALL"], &[], vec![Rule::Deletion(NoParameters {})]),
447 ruleset("release", Level::Repository, &["release/*"], &[], vec![Rule::Creation(NoParameters {})]),
448 ];
449 let main = effective(&rulesets, repo(&topics), Target::Branch, "main");
450 assert!(main.default_branch);
451 let from: Vec<(&str, &str)> = main.rules.iter().map(|rule| (rule.ruleset_id.as_str(), rule.entry.rule.kind())).collect();
452 assert_eq!(
453 from,
454 vec![("org", "deletion"), ("a-main", "deletion"), ("a-main", "non_fast_forward"), ("b-dry", "required_linear_history")]
455 );
456 assert_eq!(main.rulesets.len(), 3);
457 let release = effective(&rulesets, repo(&topics), Target::Branch, "release/1");
458 assert_eq!(release.rules.iter().map(|rule| rule.entry.rule.kind()).collect::<Vec<_>>(), vec!["deletion", "creation"]);
459 assert!(effective(&rulesets, repo(&topics), Target::Tag, "main").rules.is_empty());
460 }
461
462 #[test]
463 fn applicable_rulesets_carry_the_actors_bypass() {
464 let topics = Vec::new();
465 let mut guarded = ruleset("a", Level::Repository, &["~DEFAULT_BRANCH"], &[], vec![Rule::Update(NoParameters {})]);
466 guarded.spec.bypass_actors = vec![entry(ActorKind::Role, "admin", BypassMode::Always)];
467 let rulesets = vec![guarded, ruleset("b", Level::Repository, &["feature/*"], &[], vec![])];
468 let found = applicable(&rulesets, repo(&topics), &["refs/heads/main".into()], Some(&person(RepoRole::Admin)), "acme");
469 assert_eq!(found.len(), 1);
470 assert_eq!(found[0].bypass, Some(BypassMode::Always));
471 assert!(applies_to_ref(&found[0], "refs/heads/main", "main"));
472 assert!(!applies_to_ref(&found[0], "refs/tags/main", "main"));
473 let none = applicable(&rulesets, repo(&topics), &["refs/heads/main".into()], Some(&person(RepoRole::Write)), "acme");
474 assert_eq!(none[0].bypass, None);
475 }
476}