| 1 | |
| 2 | |
| 3 | use crate::error::{Error, Result}; |
| 4 | |
| 5 | |
| 6 | pub const MAX_PATTERN_BYTES: usize = 200; |
| 7 | |
| 8 | pub const MAX_REQUIRED_APPROVALS: u32 = 20; |
| 9 | |
| 10 | #[derive(Debug, Clone, PartialEq)] |
| 11 | pub 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 | |
| 18 | pub required_approvals: u32, |
| 19 | |
| 20 | pub block_on_changes_requested: bool, |
| 21 | |
| 22 | pub require_up_to_date: bool, |
| 23 | |
| 24 | |
| 25 | pub restrict_push: bool, |
| 26 | pub created_at: String, |
| 27 | } |
| 28 | |
| 29 | |
| 30 | #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] |
| 31 | pub 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 | |
| 40 | impl RuleFlags { |
| 41 | |
| 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 | |
| 52 | #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] |
| 53 | pub 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 | |
| 63 | pub 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 | |
| 73 | |
| 74 | |
| 75 | pub 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)] |
| 115 | enum Tok { |
| 116 | Char(char), |
| 117 | |
| 118 | Star, |
| 119 | |
| 120 | AnyStar, |
| 121 | } |
| 122 | |
| 123 | fn 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 | |
| 142 | |
| 143 | pub fn pattern_matches(pattern: &str, branch: &str) -> bool { |
| 144 | let chars: Vec<char> = branch.chars().collect(); |
| 145 | let n = chars.len(); |
| 146 | |
| 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 | |
| 175 | fn is_literal(pattern: &str) -> bool { |
| 176 | !pattern.contains('*') |
| 177 | } |
| 178 | |
| 179 | |
| 180 | |
| 181 | |
| 182 | |
| 183 | |
| 184 | pub 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 | |
| 190 | |
| 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)] |
| 223 | mod 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 | |
| 319 | let rules = [rule("*", true, true, "0"), rule("rel*", false, false, "1")]; |
| 320 | assert_eq!(effective(&rules, "main", "release"), flags(false, false)); |
| 321 | |
| 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 | |
| 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 | } |