Skip to content
477 linesCodeBlameRaw
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 on its own, as `System`: the merge queue, security updates,
179 // a merge its settings make. An agent acting through a token is
180 // named only by that token.
181 ActorKind::G1t => who.kind == Who::G1t,
182 ActorKind::Token => {
183 (value.eq_ignore_ascii_case("workspace") && who.kind == Who::Token)
184 || who.token_id.as_deref().is_some_and(|id| !value.is_empty() && id == value)
185 }
186 _ if who.kind != Who::Person => false,
187 ActorKind::User => !value.is_empty() && who.username.eq_ignore_ascii_case(value.trim_start_matches('@')),
188 ActorKind::Team => !value.is_empty() && who.teams.contains(&team_key(value, workspace)),
189 ActorKind::Role => match value.to_ascii_lowercase().as_str() {
190 "owner" => who.owner,
191 role => RepoRole::parse(role).is_some_and(|wanted| who.role.is_some_and(|has| has >= wanted)),
192 },
193 }
194}
195
196/// How the actor may bypass a ruleset with these bypass actors, if at all.
197/// `always` wins over `pull_requests` when both name them.
198pub fn bypass(actors: &[BypassActor], who: &ActorFacts, workspace: &str) -> Option<BypassMode> {
199 let modes: Vec<BypassMode> = actors.iter().filter(|entry| names(entry, who, workspace)).map(|entry| entry.mode).collect();
200 if modes.contains(&BypassMode::Always) {
201 Some(BypassMode::Always)
202 } else {
203 modes.first().copied()
204 }
205}
206
207/// Whether any bypass list names a team: only then are the actor's teams
208/// worth looking up.
209pub fn names_teams(rulesets: &[Ruleset]) -> bool {
210 rulesets
211 .iter()
212 .any(|ruleset| ruleset.spec.bypass_actors.iter().any(|entry| entry.kind == ActorKind::Team))
213}
214
215/// The rulesets that hold in `repo` for any of `refs` (full refs), as a
216/// service applies them, with how the actor may bypass each.
217pub fn applicable(rulesets: &[Ruleset], repo: RepoFacts<'_>, refs: &[String], who: Option<&ActorFacts>, workspace: &str) -> Vec<Applicable> {
218 rulesets
219 .iter()
220 .filter(|ruleset| holds_in(ruleset, repo))
221 .filter(|ruleset| {
222 refs.iter().any(|git_ref| match Target::of_ref(git_ref) {
223 Some((target, name)) => {
224 target == ruleset.spec.target
225 && ref_matches(&ruleset.spec.conditions.ref_name, target, name, repo.default_branch)
226 }
227 None => false,
228 })
229 })
230 .map(|ruleset| Applicable {
231 id: ruleset.id.clone(),
232 name: ruleset.spec.name.clone(),
233 level: ruleset.level,
234 enforcement: ruleset.spec.enforcement,
235 target: ruleset.spec.target,
236 conditions: ruleset.spec.conditions.ref_name.clone(),
237 rules: ruleset.spec.rules.clone(),
238 bypass: who.and_then(|who| bypass(&ruleset.spec.bypass_actors, who, workspace)),
239 })
240 .collect()
241}
242
243/// Whether an applicable ruleset holds for a full ref.
244pub fn applies_to_ref(ruleset: &Applicable, git_ref: &str, default_branch: &str) -> bool {
245 match Target::of_ref(git_ref) {
246 Some((target, name)) => target == ruleset.target && ref_matches(&ruleset.conditions, target, name, default_branch),
247 None => false,
248 }
249}
250
251/// Every rule that holds for one branch or tag: active rulesets' first,
252/// then those being evaluated, each with where it comes from.
253pub fn effective(rulesets: &[Ruleset], repo: RepoFacts<'_>, target: Target, name: &str) -> EffectiveRules {
254 let mut holding: Vec<&Ruleset> = rulesets
255 .iter()
256 .filter(|ruleset| holds_in(ruleset, repo))
257 .filter(|ruleset| ruleset.spec.target == target)
258 .filter(|ruleset| ref_matches(&ruleset.spec.conditions.ref_name, target, name, repo.default_branch))
259 .collect();
260 // Active before evaluate; workspace before repository; then by name.
261 holding.sort_by_key(|ruleset| {
262 (
263 ruleset.spec.enforcement != Enforcement::Active,
264 ruleset.level != Level::Workspace,
265 ruleset.spec.name.to_lowercase(),
266 )
267 });
268 EffectiveRules {
269 name: name.to_owned(),
270 target,
271 default_branch: target == Target::Branch && name == repo.default_branch,
272 rules: holding
273 .iter()
274 .flat_map(|ruleset| {
275 ruleset.spec.rules.iter().map(|entry| EffectiveRule {
276 entry: entry.clone(),
277 ruleset_id: ruleset.id.clone(),
278 ruleset_name: ruleset.spec.name.clone(),
279 level: ruleset.level,
280 enforcement: ruleset.spec.enforcement,
281 })
282 })
283 .collect(),
284 rulesets: holding
285 .iter()
286 .map(|ruleset| RulesetSummary {
287 id: ruleset.id.clone(),
288 name: ruleset.spec.name.clone(),
289 level: ruleset.level,
290 enforcement: ruleset.spec.enforcement,
291 bypass_actors: ruleset.spec.bypass_actors.clone(),
292 })
293 .collect(),
294 }
295}
296
297#[cfg(test)]
298mod tests {
299 use super::*;
300 use g1t_contracts::rules::{BypassMode, Conditions, NoParameters, Rule, RuleEntry, RulesetSpec};
301
302 pub(crate) fn ruleset(id: &str, level: Level, include: &[&str], exclude: &[&str], rules: Vec<Rule>) -> Ruleset {
303 Ruleset {
304 id: id.into(),
305 level,
306 workspace: "acme".into(),
307 repo_id: (level == Level::Repository).then(|| "rep_1".to_owned()),
308 repository: None,
309 spec: RulesetSpec {
310 name: id.into(),
311 conditions: Conditions {
312 ref_name: RefCondition {
313 include: include.iter().map(|p| (*p).to_owned()).collect(),
314 exclude: exclude.iter().map(|p| (*p).to_owned()).collect(),
315 },
316 repository: None,
317 },
318 rules: rules.into_iter().map(RuleEntry::everyone).collect(),
319 ..RulesetSpec::default()
320 },
321 source: None,
322 created_by: "ada".into(),
323 created_at: String::new(),
324 updated_by: "ada".into(),
325 updated_at: String::new(),
326 }
327 }
328
329 fn repo<'a>(topics: &'a [String]) -> RepoFacts<'a> {
330 RepoFacts { id: "rep_1", name: "web", private: true, topics, default_branch: "main" }
331 }
332
333 #[test]
334 fn names_match_patterns_the_default_branch_and_all() {
335 let condition = |include: &[&str], exclude: &[&str]| RefCondition {
336 include: include.iter().map(|p| (*p).to_owned()).collect(),
337 exclude: exclude.iter().map(|p| (*p).to_owned()).collect(),
338 };
339 assert!(ref_matches(&condition(&["~DEFAULT_BRANCH"], &[]), Target::Branch, "main", "main"));
340 assert!(!ref_matches(&condition(&["~DEFAULT_BRANCH"], &[]), Target::Branch, "dev", "main"));
341 assert!(!ref_matches(&condition(&["~DEFAULT_BRANCH"], &[]), Target::Tag, "main", "main"));
342 assert!(ref_matches(&condition(&["~ALL"], &["dependabot/**"]), Target::Branch, "feature/x", "main"));
343 assert!(!ref_matches(&condition(&["~ALL"], &["g1t-queue/**"]), Target::Branch, "g1t-queue/a", "main"));
344 assert!(ref_matches(&condition(&["refs/heads/release/*"], &[]), Target::Branch, "release/2", "main"));
345 assert!(ref_matches(&condition(&["v*"], &[]), Target::Tag, "v1.0.0", "main"));
346 assert!(!ref_matches(&condition(&[], &[]), Target::Branch, "main", "main"), "an empty include selects nothing");
347 }
348
349 #[test]
350 fn workspace_rulesets_select_repositories_by_name_visibility_and_topic() {
351 let topics = vec!["payments".to_owned()];
352 let mut condition = RepositoryCondition::default();
353 assert!(repo_matches(&condition, repo(&topics)));
354 condition.include = vec!["api-*".into()];
355 assert!(!repo_matches(&condition, repo(&topics)));
356 condition.include = vec!["W*".into()];
357 assert!(repo_matches(&condition, repo(&topics)), "names ignore case");
358 condition.exclude = vec!["web".into()];
359 assert!(!repo_matches(&condition, repo(&topics)));
360 condition.exclude.clear();
361 condition.visibility = VisibilityCondition::Public;
362 assert!(!repo_matches(&condition, repo(&topics)));
363 condition.visibility = VisibilityCondition::Private;
364 condition.topics = vec!["Payments".into()];
365 assert!(repo_matches(&condition, repo(&topics)));
366 condition.topics = vec!["docs".into()];
367 assert!(!repo_matches(&condition, repo(&topics)));
368 }
369
370 #[test]
371 fn a_repository_ruleset_holds_only_in_its_repository_and_disabled_ones_nowhere() {
372 let topics = Vec::new();
373 let own = ruleset("a", Level::Repository, &["~ALL"], &[], vec![]);
374 assert!(holds_in(&own, repo(&topics)));
375 let other = Ruleset { repo_id: Some("rep_2".into()), ..own.clone() };
376 assert!(!holds_in(&other, repo(&topics)));
377 let mut off = own.clone();
378 off.spec.enforcement = Enforcement::Disabled;
379 assert!(!holds_in(&off, repo(&topics)));
380 }
381
382 fn person(role: RepoRole) -> ActorFacts {
383 ActorFacts { username: "ada".into(), kind: Who::Person, role: Some(role), ..ActorFacts::default() }
384 }
385
386 fn entry(kind: ActorKind, value: &str, mode: BypassMode) -> BypassActor {
387 BypassActor { kind, value: value.into(), mode }
388 }
389
390 #[test]
391 fn nobody_bypasses_by_default() {
392 assert_eq!(bypass(&[], &person(RepoRole::Admin), "acme"), None);
393 let g1t = ActorFacts { kind: Who::G1t, username: "g1t".into(), ..ActorFacts::default() };
394 assert_eq!(bypass(&[], &g1t, "acme"), None);
395 }
396
397 #[test]
398 fn roles_people_and_teams_bypass_as_listed() {
399 let list = [entry(ActorKind::Role, "maintain", BypassMode::PullRequests)];
400 assert_eq!(bypass(&list, &person(RepoRole::Admin), "acme"), Some(BypassMode::PullRequests));
401 assert_eq!(bypass(&list, &person(RepoRole::Write), "acme"), None);
402 let both = [
403 entry(ActorKind::Role, "write", BypassMode::PullRequests),
404 entry(ActorKind::User, "@Ada", BypassMode::Always),
405 ];
406 assert_eq!(bypass(&both, &person(RepoRole::Write), "acme"), Some(BypassMode::Always));
407 let team = [entry(ActorKind::Team, "release", BypassMode::Always)];
408 let mut ada = person(RepoRole::Read);
409 assert_eq!(bypass(&team, &ada, "acme"), None);
410 ada.teams.push("acme/release".into());
411 assert_eq!(bypass(&team, &ada, "acme"), Some(BypassMode::Always));
412 let owners = [entry(ActorKind::Role, "owner", BypassMode::Always)];
413 assert_eq!(bypass(&owners, &ada, "acme"), None);
414 ada.owner = true;
415 assert_eq!(bypass(&owners, &ada, "acme"), Some(BypassMode::Always));
416 }
417
418 #[test]
419 fn agents_bypass_only_when_their_token_is_listed() {
420 let agent = ActorFacts {
421 username: "g1t".into(),
422 kind: Who::Agent,
423 role: Some(RepoRole::Admin),
424 owner: true,
425 token_id: Some("tok_1".into()),
426 ..ActorFacts::default()
427 };
428 let admins = [entry(ActorKind::Role, "admin", BypassMode::Always), entry(ActorKind::Role, "owner", BypassMode::Always)];
429 assert_eq!(bypass(&admins, &agent, "acme"), None, "an agent does not take its person's role");
430 assert_eq!(bypass(&[entry(ActorKind::G1t, "", BypassMode::Always)], &agent, "acme"), None, "g1t names g1t itself, not agents");
431 assert_eq!(bypass(&[entry(ActorKind::Token, "tok_1", BypassMode::Always)], &agent, "acme"), Some(BypassMode::Always));
432 assert_eq!(bypass(&[entry(ActorKind::Token, "tok_2", BypassMode::Always)], &agent, "acme"), None);
433 let system = ActorFacts { kind: Who::G1t, ..ActorFacts::default() };
434 assert_eq!(bypass(&[entry(ActorKind::G1t, "", BypassMode::PullRequests)], &system, "acme"), Some(BypassMode::PullRequests));
435 let token = ActorFacts { kind: Who::Token, ..ActorFacts::default() };
436 assert_eq!(bypass(&[entry(ActorKind::Token, "workspace", BypassMode::Always)], &token, "acme"), Some(BypassMode::Always));
437 }
438
439 #[test]
440 fn effective_rules_stack_every_ruleset_that_holds() {
441 let topics = Vec::new();
442 let mut evaluate = ruleset("b-dry", Level::Repository, &["main"], &[], vec![Rule::RequiredLinearHistory(NoParameters {})]);
443 evaluate.spec.enforcement = Enforcement::Evaluate;
444 let rulesets = vec![
445 evaluate,
446 ruleset("a-main", Level::Repository, &["~DEFAULT_BRANCH"], &[], vec![Rule::Deletion(NoParameters {}), Rule::NonFastForward(NoParameters {})]),
447 ruleset("org", Level::Workspace, &["~ALL"], &[], vec![Rule::Deletion(NoParameters {})]),
448 ruleset("release", Level::Repository, &["release/*"], &[], vec![Rule::Creation(NoParameters {})]),
449 ];
450 let main = effective(&rulesets, repo(&topics), Target::Branch, "main");
451 assert!(main.default_branch);
452 let from: Vec<(&str, &str)> = main.rules.iter().map(|rule| (rule.ruleset_id.as_str(), rule.entry.rule.kind())).collect();
453 assert_eq!(
454 from,
455 vec![("org", "deletion"), ("a-main", "deletion"), ("a-main", "non_fast_forward"), ("b-dry", "required_linear_history")]
456 );
457 assert_eq!(main.rulesets.len(), 3);
458 let release = effective(&rulesets, repo(&topics), Target::Branch, "release/1");
459 assert_eq!(release.rules.iter().map(|rule| rule.entry.rule.kind()).collect::<Vec<_>>(), vec!["deletion", "creation"]);
460 assert!(effective(&rulesets, repo(&topics), Target::Tag, "main").rules.is_empty());
461 }
462
463 #[test]
464 fn applicable_rulesets_carry_the_actors_bypass() {
465 let topics = Vec::new();
466 let mut guarded = ruleset("a", Level::Repository, &["~DEFAULT_BRANCH"], &[], vec![Rule::Update(NoParameters {})]);
467 guarded.spec.bypass_actors = vec![entry(ActorKind::Role, "admin", BypassMode::Always)];
468 let rulesets = vec![guarded, ruleset("b", Level::Repository, &["feature/*"], &[], vec![])];
469 let found = applicable(&rulesets, repo(&topics), &["refs/heads/main".into()], Some(&person(RepoRole::Admin)), "acme");
470 assert_eq!(found.len(), 1);
471 assert_eq!(found[0].bypass, Some(BypassMode::Always));
472 assert!(applies_to_ref(&found[0], "refs/heads/main", "main"));
473 assert!(!applies_to_ref(&found[0], "refs/tags/main", "main"));
474 let none = applicable(&rulesets, repo(&topics), &["refs/heads/main".into()], Some(&person(RepoRole::Write)), "acme");
475 assert_eq!(none[0].bypass, None);
476 }
477}