forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
12.3 KiB
1//! Branch protection rules: pattern syntax, validation, matching and effective-rule resolution.
2
3use crate::error::{Error, Result};
4
5/// Longest accepted pattern, in bytes.
6pub const MAX_PATTERN_BYTES: usize = 200;
7/// Largest accepted `required_approvals`.
8pub const MAX_REQUIRED_APPROVALS: u32 = 20;
9
10#[derive(Debug, Clone, PartialEq)]
11pub struct BranchRule {
12 pub id: String,
13 pub repo_id: String,
14 pub pattern: String,
15 pub allow_force_push: bool,
16 pub allow_deletion: bool,
17 /// Approvals from Write-or-higher reviewers that a pull request needs before it can merge.
18 pub required_approvals: u32,
19 /// A changes-requested review from a Write-or-higher reviewer blocks the merge.
20 pub block_on_changes_requested: bool,
21 /// The pull request head must contain the base tip before it can merge.
22 pub require_up_to_date: bool,
23 /// Direct pushes to the branch are rejected for everyone but repo admins. Merges through
24 /// pull requests are unaffected.
25 pub restrict_push: bool,
26 pub created_at: String,
27}
28
29/// The settings a rule saves, without its identity.
30#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
31pub struct RuleFlags {
32 pub allow_force_push: bool,
33 pub allow_deletion: bool,
34 pub required_approvals: u32,
35 pub block_on_changes_requested: bool,
36 pub require_up_to_date: bool,
37 pub restrict_push: bool,
38}
39
40impl RuleFlags {
41 /// Only the force-push and deletion flags set; the merge and push rules off.
42 pub fn basic(allow_force_push: bool, allow_deletion: bool) -> Self {
43 Self {
44 allow_force_push,
45 allow_deletion,
46 ..Self::default()
47 }
48 }
49}
50
51/// The settings that apply to one branch. `Default` is the unprotected branch.
52#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
53pub struct Protection {
54 pub allow_force_push: bool,
55 pub allow_deletion: bool,
56 pub required_approvals: u32,
57 pub block_on_changes_requested: bool,
58 pub require_up_to_date: bool,
59 pub restrict_push: bool,
60}
61
62/// Rejects `required_approvals` above `MAX_REQUIRED_APPROVALS`.
63pub fn validate_required_approvals(n: u32) -> Result<()> {
64 if n > MAX_REQUIRED_APPROVALS {
65 return Err(Error::Invalid(format!(
66 "required approvals must be between 0 and {MAX_REQUIRED_APPROVALS}"
67 )));
68 }
69 Ok(())
70}
71
72/// Pattern syntax: a literal branch name, `*` matches any run of chars except `/`, `**` matches anything
73/// including `/`. Max 200 bytes, it must be a valid branch name otherwise (no `..`, no leading `-`, no
74/// control or space chars, no `~^:?[\`). The pattern must not include the `refs/heads/` prefix.
75pub fn validate_pattern(p: &str) -> Result<()> {
76 let invalid = |why: &str| Err(Error::Invalid(format!("branch pattern {why}")));
77 if p.is_empty() {
78 return invalid("is empty");
79 }
80 if p.len() > MAX_PATTERN_BYTES {
81 return invalid(&format!("is longer than {MAX_PATTERN_BYTES} bytes"));
82 }
83 if p.starts_with("refs/heads/") {
84 return invalid("must not include the refs/heads/ prefix");
85 }
86 if p == "@" || p.contains("@{") {
87 return invalid("contains a reserved sequence");
88 }
89 if p.contains("..") {
90 return invalid("must not contain '..'");
91 }
92 if p.starts_with('-') {
93 return invalid("must not start with '-'");
94 }
95 if p.starts_with('/') || p.ends_with('/') || p.contains("//") {
96 return invalid("has an empty path segment");
97 }
98 if p.split('/')
99 .any(|seg| seg.starts_with('.') || seg.ends_with(".lock"))
100 {
101 return invalid("has a path segment that starts with '.' or ends with '.lock'");
102 }
103 if p.ends_with('.') {
104 return invalid("must not end with '.'");
105 }
106 if p.chars()
107 .any(|c| c.is_control() || c.is_whitespace() || "~^:?[\\".contains(c))
108 {
109 return invalid("contains a forbidden character");
110 }
111 Ok(())
112}
113
114#[derive(Clone, Copy, PartialEq, Eq)]
115enum Tok {
116 Char(char),
117 /// `*`: any run of chars except `/`.
118 Star,
119 /// `**`: any run of chars, `/` included.
120 AnyStar,
121}
122
123fn tokenize(pattern: &str) -> Vec<Tok> {
124 let mut out = Vec::new();
125 let mut chars = pattern.chars().peekable();
126 while let Some(c) = chars.next() {
127 if c != '*' {
128 out.push(Tok::Char(c));
129 continue;
130 }
131 let mut run = 1;
132 while chars.peek() == Some(&'*') {
133 chars.next();
134 run += 1;
135 }
136 out.push(if run >= 2 { Tok::AnyStar } else { Tok::Star });
137 }
138 out
139}
140
141/// Whether `branch` matches `pattern`. Dynamic programming over (token, char) positions, so the
142/// cost is bounded by pattern length times branch length.
143pub fn pattern_matches(pattern: &str, branch: &str) -> bool {
144 let chars: Vec<char> = branch.chars().collect();
145 let n = chars.len();
146 // reach[j]: the tokens consumed so far can match chars[..j].
147 let mut reach = vec![false; n + 1];
148 reach[0] = true;
149 for tok in tokenize(pattern) {
150 let mut next = vec![false; n + 1];
151 match tok {
152 Tok::Char(c) => {
153 for j in 0..n {
154 next[j + 1] = reach[j] && chars[j] == c;
155 }
156 }
157 Tok::Star => {
158 next[0] = reach[0];
159 for j in 1..=n {
160 next[j] = reach[j] || (next[j - 1] && chars[j - 1] != '/');
161 }
162 }
163 Tok::AnyStar => {
164 next[0] = reach[0];
165 for j in 1..=n {
166 next[j] = reach[j] || next[j - 1];
167 }
168 }
169 }
170 reach = next;
171 }
172 reach[n]
173}
174
175fn is_literal(pattern: &str) -> bool {
176 !pattern.contains('*')
177}
178
179/// Rules for `branch` (short name, without refs/heads/):
180/// 1. If any rule matches: the most specific one wins. A literal (no `*`) beats a glob; among globs the
181/// longest pattern wins, and ties go to the earliest created_at.
182/// 2. Otherwise, if `branch == default_branch`: built-in protection {force_push: false, deletion: false}.
183/// 3. Otherwise: unprotected {force_push: true, deletion: true}.
184pub fn effective(rules: &[BranchRule], default_branch: &str, branch: &str) -> Protection {
185 let best = rules
186 .iter()
187 .filter(|r| pattern_matches(&r.pattern, branch))
188 .min_by(|a, b| {
189 // Literal first, then longer pattern, then earliest creation, then pattern text
190 // so the result never depends on the order of `rules`.
191 is_literal(&b.pattern)
192 .cmp(&is_literal(&a.pattern))
193 .then_with(|| b.pattern.len().cmp(&a.pattern.len()))
194 .then_with(|| a.created_at.cmp(&b.created_at))
195 .then_with(|| a.pattern.cmp(&b.pattern))
196 });
197 if let Some(rule) = best {
198 return Protection {
199 allow_force_push: rule.allow_force_push,
200 allow_deletion: rule.allow_deletion,
201 required_approvals: rule.required_approvals,
202 block_on_changes_requested: rule.block_on_changes_requested,
203 require_up_to_date: rule.require_up_to_date,
204 restrict_push: rule.restrict_push,
205 };
206 }
207 if branch == default_branch {
208 Protection {
209 allow_force_push: false,
210 allow_deletion: false,
211 ..Protection::default()
212 }
213 } else {
214 Protection {
215 allow_force_push: true,
216 allow_deletion: true,
217 ..Protection::default()
218 }
219 }
220}
221
222#[cfg(test)]
223mod tests {
224 use super::*;
225
226 fn rule(pattern: &str, force: bool, deletion: bool, created: &str) -> BranchRule {
227 BranchRule {
228 id: format!("id-{pattern}"),
229 repo_id: "r".into(),
230 pattern: pattern.into(),
231 allow_force_push: force,
232 allow_deletion: deletion,
233 required_approvals: 0,
234 block_on_changes_requested: false,
235 require_up_to_date: false,
236 restrict_push: false,
237 created_at: created.into(),
238 }
239 }
240
241 fn flags(force: bool, deletion: bool) -> Protection {
242 Protection {
243 allow_force_push: force,
244 allow_deletion: deletion,
245 ..Protection::default()
246 }
247 }
248
249 #[test]
250 fn pattern_matches_cases() {
251 assert!(pattern_matches("main", "main"));
252 assert!(!pattern_matches("main", "main2"));
253 assert!(pattern_matches("release/*", "release/1.0"));
254 assert!(!pattern_matches("release/*", "release/a/b"));
255 assert!(pattern_matches("release/**", "release/a/b"));
256 assert!(pattern_matches("*", "feature"));
257 assert!(!pattern_matches("*", "a/b"));
258 assert!(pattern_matches("**", "anything/at/all"));
259 assert!(pattern_matches("**", "x"));
260 assert!(!pattern_matches("", "x"));
261 assert!(pattern_matches("a*b", "ab"));
262 assert!(pattern_matches("a*b", "axxb"));
263 assert!(!pattern_matches("a*b", "a/b"));
264 }
265
266 #[test]
267 fn validate_pattern_rejects_bad_input() {
268 for bad in [
269 "refs/heads/main",
270 "a..b",
271 "-x",
272 "a b",
273 "a~b",
274 "",
275 &"a".repeat(201),
276 ] {
277 assert!(validate_pattern(bad).is_err(), "{bad:?} should be rejected");
278 }
279 }
280
281 #[test]
282 fn validate_pattern_accepts_good_input() {
283 for good in [
284 "main",
285 "release/*",
286 "release/**",
287 "**",
288 "a".repeat(200).as_str(),
289 ] {
290 assert!(
291 validate_pattern(good).is_ok(),
292 "{good:?} should be accepted"
293 );
294 }
295 }
296
297 #[test]
298 fn effective_without_rules_protects_only_default() {
299 let none: &[BranchRule] = &[];
300 assert_eq!(effective(none, "main", "main"), flags(false, false));
301 assert_eq!(effective(none, "main", "feature"), flags(true, true));
302 }
303
304 #[test]
305 fn effective_literal_allows_force_push_on_main() {
306 let rules = [rule("main", true, false, "1")];
307 assert_eq!(effective(&rules, "main", "main"), flags(true, false));
308 }
309
310 #[test]
311 fn effective_literal_beats_glob() {
312 let rules = [rule("*", false, false, "1"), rule("main", true, true, "2")];
313 assert_eq!(effective(&rules, "main", "main"), flags(true, true));
314 }
315
316 #[test]
317 fn effective_longer_glob_wins() {
318 // Both `*` and `rel*` match `release`; the longer `rel*` wins even though `*` is older.
319 let rules = [rule("*", true, true, "0"), rule("rel*", false, false, "1")];
320 assert_eq!(effective(&rules, "main", "release"), flags(false, false));
321 // `release/**` is longer than `release/*`, so it wins for nested names.
322 let rules = [
323 rule("release/*", true, true, "1"),
324 rule("release/**", false, false, "2"),
325 ];
326 assert_eq!(
327 effective(&rules, "main", "release/a/b"),
328 flags(false, false)
329 );
330 }
331
332 #[test]
333 fn effective_carries_merge_and_push_rules() {
334 let mut gated = rule("main", false, false, "1");
335 gated.required_approvals = 2;
336 gated.block_on_changes_requested = true;
337 gated.require_up_to_date = true;
338 gated.restrict_push = true;
339 let rules = [gated];
340 assert_eq!(
341 effective(&rules, "main", "main"),
342 Protection {
343 allow_force_push: false,
344 allow_deletion: false,
345 required_approvals: 2,
346 block_on_changes_requested: true,
347 require_up_to_date: true,
348 restrict_push: true,
349 }
350 );
351 // Unprotected and built-in default branches carry no merge rules.
352 assert_eq!(effective(&rules, "main", "feature").required_approvals, 0);
353 assert!(!effective(&[], "main", "main").restrict_push);
354 }
355
356 #[test]
357 fn validate_required_approvals_bounds() {
358 assert!(validate_required_approvals(0).is_ok());
359 assert!(validate_required_approvals(MAX_REQUIRED_APPROVALS).is_ok());
360 assert!(validate_required_approvals(MAX_REQUIRED_APPROVALS + 1).is_err());
361 }
362
363 #[test]
364 fn effective_double_star_deletion_false_protects_every_branch() {
365 let rules = [rule("**", true, false, "1")];
366 for branch in ["main", "feature", "a/b/c"] {
367 assert!(
368 !effective(&rules, "main", branch).allow_deletion,
369 "{branch} should not allow deletion"
370 );
371 }
372 }
373}