Skip to content

g1t/crates/rules/src/push.rs

312 lines14,176 bytesCodeBlame
1//! Judging a change to a branch or tag that does not come from merging a
2//! pull request: a push, a branch created, deleted or renamed through g1t,
3//! or a commit made on the site.
4
5use g1t_contracts::rules::{Applicable, CommitFacts, Rule, Target};
6
7use crate::content::{self, Problem};
8use crate::outcome::Judged;
9use crate::select::{Who, applies_to_ref};
10use crate::text;
11
12/// One ref a change moves.
13#[derive(Clone, Debug, Default, PartialEq, Eq)]
14pub struct RefChange {
15 /// The full ref.
16 pub git_ref: String,
17 /// Where it pointed; `None` when it is created.
18 pub old: Option<String>,
19 /// Where it will; `None` when it is deleted.
20 pub new: Option<String>,
21 /// For an update: whether `new` contains `old`. `None` if unknown.
22 pub fast_forward: Option<bool>,
23 /// The commits the change adds to the ref, newest first.
24 pub commits: Vec<CommitFacts>,
25 /// Whether `commits` is every commit it adds, each read in full.
26 pub complete: bool,
27}
28
29impl RefChange {
30 fn created(&self) -> bool {
31 self.old.is_none() && self.new.is_some()
32 }
33
34 fn deleted(&self) -> bool {
35 self.new.is_none()
36 }
37
38 fn updated(&self) -> bool {
39 self.old.is_some() && self.new.is_some()
40 }
41}
42
43fn short_name(git_ref: &str) -> &str {
44 Target::of_ref(git_ref).map_or(git_ref, |(_, name)| name)
45}
46
47/// The problems one rule finds in a change, for an actor of kind `who`.
48fn rule_problems(rule: &Rule, change: &RefChange) -> Vec<Problem> {
49 let name = short_name(&change.git_ref);
50 let tag = change.git_ref.starts_with("refs/tags/");
51 let what = if tag { "tag" } else { "branch" };
52 match rule {
53 Rule::Creation(_) if change.created() => vec![Problem::new(
54 format!("Only people this ruleset lets bypass it may create the {what} {name}."),
55 format!("Use a {what} name the ruleset does not cover, or ask someone who may bypass it."),
56 )],
57 Rule::Update(_) if change.updated() => vec![Problem::new(
58 format!("Only people this ruleset lets bypass it may push to {name}."),
59 "Ask someone who may bypass it, or change it through a pull request.",
60 )],
61 Rule::Deletion(_) if change.deleted() => vec![Problem::new(
62 format!("The {what} {name} cannot be deleted."),
63 "Ask someone who may bypass this ruleset.",
64 )],
65 Rule::NonFastForward(_) if change.updated() && change.fast_forward == Some(false) => vec![Problem::new(
66 format!("Force pushes to {name} are blocked: the push would rewrite its history."),
67 format!("Pull {name}, put your commits on top of it, and push without --force."),
68 )],
69 Rule::PullRequest(rule) if change.updated() && !tag && !rule.allow_direct_pushes => vec![Problem::new(
70 format!("Changes to {name} must be made through a pull request."),
71 format!("Push a branch, open a pull request into {name}, and merge it."),
72 )],
73 Rule::BranchNamePattern(pattern) | Rule::TagNamePattern(pattern) if change.created() => {
74 match text::compile(pattern) {
75 Ok(compiled) if !compiled.allows(name) => vec![Problem::new(
76 format!("The {what} name {name} does not {}.", compiled.wants()),
77 format!("Name the {what} so it does {}.", compiled.wants().trim_start_matches("not ")),
78 )],
79 _ => Vec::new(),
80 }
81 }
82 rule if content::about_content(rule) && !change.deleted() => {
83 content::problems(rule, &change.commits, change.complete)
84 }
85 _ => Vec::new(),
86 }
87}
88
89/// How every applicable ruleset judges one ref change by an actor of kind
90/// `who`. Rulesets that do not hold for its ref are left out.
91pub fn judge(rulesets: &[Applicable], default_branch: &str, who: Who, change: &RefChange) -> Vec<Judged> {
92 rulesets
93 .iter()
94 .filter(|ruleset| applies_to_ref(ruleset, &change.git_ref, default_branch))
95 .map(|ruleset| {
96 let mut judged = Judged::of(ruleset, &change.git_ref, false);
97 for entry in &ruleset.rules {
98 if !entry.applies_to.covers(who.is_agent()) {
99 continue;
100 }
101 let kind = entry.rule.kind();
102 for problem in rule_problems(&entry.rule, change) {
103 judged.add(kind, problem);
104 }
105 }
106 judged
107 })
108 .collect()
109}
110
111/// Whether any ruleset that is not bypassed has a rule that needs a
112/// change's commits read: if none, a push need not be parsed for rules.
113pub fn needs_content(rulesets: &[Applicable], who: Who) -> bool {
114 rulesets.iter().filter(|ruleset| ruleset.bypass.is_none()).any(|ruleset| {
115 ruleset
116 .rules
117 .iter()
118 .any(|entry| entry.applies_to.covers(who.is_agent()) && content::about_content(&entry.rule))
119 })
120}
121
122/// Whether any ruleset asks for every push to be read whole.
123pub fn requires_scanning(rulesets: &[Applicable], who: Who) -> bool {
124 rulesets.iter().any(|ruleset| {
125 ruleset
126 .rules
127 .iter()
128 .any(|entry| entry.applies_to.covers(who.is_agent()) && matches!(entry.rule, Rule::SecretScanning(_)))
129 })
130}
131
132/// Whether any rule asks for signatures to be verified.
133pub fn needs_signatures(rulesets: &[Applicable]) -> bool {
134 rulesets
135 .iter()
136 .any(|ruleset| ruleset.rules.iter().any(|entry| matches!(entry.rule, Rule::RequiredSignatures(_))))
137}
138
139#[cfg(test)]
140mod tests {
141 use super::*;
142 use crate::outcome::{blocking, refused, would_block};
143 use g1t_contracts::rules::{
144 AppliesTo, BypassMode, Enforcement, FilePathRule, Level, NoParameters, PatternOperator, PatternRule,
145 PullRequestRule, RefCondition, RuleEntry, Verdict,
146 };
147
148 fn ruleset(id: &str, include: &[&str], rules: Vec<RuleEntry>) -> Applicable {
149 Applicable {
150 id: id.into(),
151 name: format!("Ruleset {id}"),
152 level: Level::Repository,
153 enforcement: Enforcement::Active,
154 target: if include.iter().any(|p| p.starts_with("v")) { Target::Tag } else { Target::Branch },
155 conditions: RefCondition { include: include.iter().map(|p| (*p).to_owned()).collect(), exclude: Vec::new() },
156 rules,
157 bypass: None,
158 }
159 }
160
161 fn all(rule: Rule) -> RuleEntry {
162 RuleEntry::everyone(rule)
163 }
164
165 fn update(git_ref: &str) -> RefChange {
166 RefChange {
167 git_ref: git_ref.into(),
168 old: Some("a".repeat(40)),
169 new: Some("b".repeat(40)),
170 fast_forward: Some(true),
171 commits: Vec::new(),
172 complete: true,
173 }
174 }
175
176 #[test]
177 fn a_protected_branch_takes_changes_only_through_pull_requests() {
178 let protect = ruleset("main", &["~DEFAULT_BRANCH"], vec![all(Rule::PullRequest(PullRequestRule::default()))]);
179 let judged = judge(std::slice::from_ref(&protect), "main", Who::Person, &update("refs/heads/main"));
180 assert!(refused(&judged));
181 assert_eq!(blocking(&judged)[0].message, "Changes to main must be made through a pull request.");
182 assert_eq!(blocking(&judged)[0].rule, "pull_request");
183 // Creating it, as the first push to an empty repository does, is allowed.
184 let created = RefChange { old: None, ..update("refs/heads/main") };
185 assert!(!refused(&judge(std::slice::from_ref(&protect), "main", Who::Person, &created)));
186 // Another branch is not covered.
187 assert!(judge(&[protect], "main", Who::Person, &update("refs/heads/feature")).is_empty());
188 }
189
190 #[test]
191 fn creations_deletions_and_force_pushes() {
192 let guard = ruleset(
193 "r",
194 &["release/*"],
195 vec![all(Rule::Creation(NoParameters {})), all(Rule::Deletion(NoParameters {})), all(Rule::NonFastForward(NoParameters {}))],
196 );
197 let created = RefChange { old: None, ..update("refs/heads/release/2") };
198 assert_eq!(blocking(&judge(std::slice::from_ref(&guard), "main", Who::Person, &created))[0].rule, "creation");
199 let deleted = RefChange { new: None, ..update("refs/heads/release/2") };
200 assert_eq!(blocking(&judge(std::slice::from_ref(&guard), "main", Who::Person, &deleted))[0].rule, "deletion");
201 let forced = RefChange { fast_forward: Some(false), ..update("refs/heads/release/2") };
202 let judged = judge(std::slice::from_ref(&guard), "main", Who::Person, &forced);
203 assert_eq!(blocking(&judged)[0].message, "Force pushes to release/2 are blocked: the push would rewrite its history.");
204 assert!(!refused(&judge(&[guard], "main", Who::Person, &update("refs/heads/release/2"))));
205 }
206
207 #[test]
208 fn a_bypass_lets_the_push_through_and_is_recorded_as_one() {
209 let mut protect = ruleset("main", &["main"], vec![all(Rule::Update(NoParameters {}))]);
210 protect.bypass = Some(BypassMode::Always);
211 let judged = judge(&[protect.clone()], "main", Who::Person, &update("refs/heads/main"));
212 assert!(!refused(&judged));
213 assert_eq!(judged[0].verdict(), Verdict::Bypass);
214 // A bypass for pull requests only does not cover a push.
215 protect.bypass = Some(BypassMode::PullRequests);
216 assert!(refused(&judge(&[protect], "main", Who::Person, &update("refs/heads/main"))));
217 }
218
219 #[test]
220 fn evaluate_mode_records_without_refusing() {
221 let mut dry = ruleset("dry", &["~ALL"], vec![all(Rule::NonFastForward(NoParameters {}))]);
222 dry.enforcement = Enforcement::Evaluate;
223 let forced = RefChange { fast_forward: Some(false), ..update("refs/heads/feature") };
224 let judged = judge(&[dry], "main", Who::Person, &forced);
225 assert!(!refused(&judged));
226 assert_eq!(would_block(&judged).len(), 1);
227 assert_eq!(judged[0].verdict(), Verdict::Fail);
228 }
229
230 #[test]
231 fn rules_for_agents_hold_only_for_agents() {
232 let agents_only = RuleEntry {
233 rule: Rule::FilePathRestriction(FilePathRule { restricted_file_paths: vec![".g1t/workflows/**".into(), "CODEOWNERS".into()] }),
234 applies_to: AppliesTo::Agents,
235 };
236 let workflows = ruleset("w", &["~ALL"], vec![agents_only]);
237 let mut change = update("refs/heads/feature");
238 change.commits = vec![crate::content::tests_support::commit("c1", "x", &[".g1t/workflows/deploy.yml"])];
239 assert!(refused(&judge(std::slice::from_ref(&workflows), "main", Who::Agent, &change)));
240 assert!(refused(&judge(std::slice::from_ref(&workflows), "main", Who::G1t, &change)));
241 assert!(!refused(&judge(&[workflows], "main", Who::Person, &change)));
242 }
243
244 #[test]
245 fn names_of_new_branches_and_tags_follow_their_patterns() {
246 let branches = ruleset(
247 "n",
248 &["~ALL"],
249 vec![all(Rule::BranchNamePattern(PatternRule {
250 name: String::new(),
251 operator: PatternOperator::Regex,
252 pattern: "^(main|(feature|fix)/.+)$".into(),
253 negate: false,
254 }))],
255 );
256 let bad = RefChange { old: None, ..update("refs/heads/stuff") };
257 assert_eq!(
258 blocking(&judge(std::slice::from_ref(&branches), "main", Who::Person, &bad))[0].message,
259 "The branch name stuff does not match /^(main|(feature|fix)/.+)$/."
260 );
261 let good = RefChange { old: None, ..update("refs/heads/feature/rules") };
262 assert!(!refused(&judge(std::slice::from_ref(&branches), "main", Who::Person, &good)));
263 // Pushing to an existing branch is not naming it.
264 assert!(!refused(&judge(&[branches], "main", Who::Person, &update("refs/heads/stuff"))));
265 let tags = ruleset(
266 "t",
267 &["v*", "~ALL"],
268 vec![all(Rule::TagNamePattern(PatternRule {
269 name: "Semantic versions".into(),
270 operator: PatternOperator::Regex,
271 pattern: r"^v\d+\.\d+\.\d+$".into(),
272 negate: false,
273 }))],
274 );
275 let tag = RefChange { old: None, ..update("refs/tags/v1") };
276 assert!(refused(&judge(&[tags], "main", Who::Person, &tag)));
277 }
278
279 #[test]
280 fn content_rules_need_the_change_read_whole() {
281 let signed = ruleset("s", &["~ALL"], vec![all(Rule::RequiredSignatures(NoParameters {}))]);
282 assert!(needs_content(std::slice::from_ref(&signed), Who::Person));
283 assert!(needs_signatures(std::slice::from_ref(&signed)));
284 let unread = RefChange { complete: false, ..update("refs/heads/feature") };
285 assert_eq!(
286 blocking(&judge(std::slice::from_ref(&signed), "main", Who::Person, &unread))[0].message,
287 "The change is too large for g1t to check against this rule."
288 );
289 let mut bypassed = signed;
290 bypassed.bypass = Some(BypassMode::Always);
291 assert!(!needs_content(&[bypassed], Who::Person), "a bypass actor's push need not be read");
292 let scan = ruleset("x", &["~ALL"], vec![all(Rule::SecretScanning(NoParameters {}))]);
293 assert!(requires_scanning(&[scan], Who::Agent));
294 }
295
296 #[test]
297 fn deleting_a_branch_checks_no_commits() {
298 let signed = ruleset("s", &["~ALL"], vec![all(Rule::RequiredSignatures(NoParameters {}))]);
299 let deleted = RefChange { new: None, complete: false, ..update("refs/heads/feature") };
300 assert!(!refused(&judge(&[signed], "main", Who::Person, &deleted)));
301 }
302
303 #[test]
304 fn several_rulesets_stack() {
305 let a = ruleset("a", &["main"], vec![all(Rule::NonFastForward(NoParameters {}))]);
306 let b = ruleset("b", &["~ALL"], vec![all(Rule::PullRequest(PullRequestRule::default()))]);
307 let forced = RefChange { fast_forward: Some(false), ..update("refs/heads/main") };
308 let judged = judge(&[a, b], "main", Who::Person, &forced);
309 let rules: Vec<&str> = blocking(&judged).iter().map(|violation| violation.rule.as_str()).collect();
310 assert_eq!(rules, vec!["non_fast_forward", "pull_request"]);
311 }
312}