//! Branch protection rules: pattern syntax, validation, matching and effective-rule resolution. use crate::error::{Error, Result}; /// Longest accepted pattern, in bytes. pub const MAX_PATTERN_BYTES: usize = 200; /// Largest accepted `required_approvals`. pub const MAX_REQUIRED_APPROVALS: u32 = 20; #[derive(Debug, Clone, PartialEq)] pub struct BranchRule { pub id: String, pub repo_id: String, pub pattern: String, pub allow_force_push: bool, pub allow_deletion: bool, /// Approvals from Write-or-higher reviewers that a pull request needs before it can merge. pub required_approvals: u32, /// A changes-requested review from a Write-or-higher reviewer blocks the merge. pub block_on_changes_requested: bool, /// The pull request head must contain the base tip before it can merge. pub require_up_to_date: bool, /// Direct pushes to the branch are rejected for everyone but repo admins. Merges through /// pull requests are unaffected. pub restrict_push: bool, pub created_at: String, } /// The settings a rule saves, without its identity. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub struct RuleFlags { pub allow_force_push: bool, pub allow_deletion: bool, pub required_approvals: u32, pub block_on_changes_requested: bool, pub require_up_to_date: bool, pub restrict_push: bool, } impl RuleFlags { /// Only the force-push and deletion flags set; the merge and push rules off. pub fn basic(allow_force_push: bool, allow_deletion: bool) -> Self { Self { allow_force_push, allow_deletion, ..Self::default() } } } /// The settings that apply to one branch. `Default` is the unprotected branch. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub struct Protection { pub allow_force_push: bool, pub allow_deletion: bool, pub required_approvals: u32, pub block_on_changes_requested: bool, pub require_up_to_date: bool, pub restrict_push: bool, } /// Rejects `required_approvals` above `MAX_REQUIRED_APPROVALS`. pub fn validate_required_approvals(n: u32) -> Result<()> { if n > MAX_REQUIRED_APPROVALS { return Err(Error::Invalid(format!( "required approvals must be between 0 and {MAX_REQUIRED_APPROVALS}" ))); } Ok(()) } /// Pattern syntax: a literal branch name, `*` matches any run of chars except `/`, `**` matches anything /// including `/`. Max 200 bytes, it must be a valid branch name otherwise (no `..`, no leading `-`, no /// control or space chars, no `~^:?[\`). The pattern must not include the `refs/heads/` prefix. pub fn validate_pattern(p: &str) -> Result<()> { let invalid = |why: &str| Err(Error::Invalid(format!("branch pattern {why}"))); if p.is_empty() { return invalid("is empty"); } if p.len() > MAX_PATTERN_BYTES { return invalid(&format!("is longer than {MAX_PATTERN_BYTES} bytes")); } if p.starts_with("refs/heads/") { return invalid("must not include the refs/heads/ prefix"); } if p == "@" || p.contains("@{") { return invalid("contains a reserved sequence"); } if p.contains("..") { return invalid("must not contain '..'"); } if p.starts_with('-') { return invalid("must not start with '-'"); } if p.starts_with('/') || p.ends_with('/') || p.contains("//") { return invalid("has an empty path segment"); } if p.split('/') .any(|seg| seg.starts_with('.') || seg.ends_with(".lock")) { return invalid("has a path segment that starts with '.' or ends with '.lock'"); } if p.ends_with('.') { return invalid("must not end with '.'"); } if p.chars() .any(|c| c.is_control() || c.is_whitespace() || "~^:?[\\".contains(c)) { return invalid("contains a forbidden character"); } Ok(()) } #[derive(Clone, Copy, PartialEq, Eq)] enum Tok { Char(char), /// `*`: any run of chars except `/`. Star, /// `**`: any run of chars, `/` included. AnyStar, } fn tokenize(pattern: &str) -> Vec { let mut out = Vec::new(); let mut chars = pattern.chars().peekable(); while let Some(c) = chars.next() { if c != '*' { out.push(Tok::Char(c)); continue; } let mut run = 1; while chars.peek() == Some(&'*') { chars.next(); run += 1; } out.push(if run >= 2 { Tok::AnyStar } else { Tok::Star }); } out } /// Whether `branch` matches `pattern`. Dynamic programming over (token, char) positions, so the /// cost is bounded by pattern length times branch length. pub fn pattern_matches(pattern: &str, branch: &str) -> bool { let chars: Vec = branch.chars().collect(); let n = chars.len(); // reach[j]: the tokens consumed so far can match chars[..j]. let mut reach = vec![false; n + 1]; reach[0] = true; for tok in tokenize(pattern) { let mut next = vec![false; n + 1]; match tok { Tok::Char(c) => { for j in 0..n { next[j + 1] = reach[j] && chars[j] == c; } } Tok::Star => { next[0] = reach[0]; for j in 1..=n { next[j] = reach[j] || (next[j - 1] && chars[j - 1] != '/'); } } Tok::AnyStar => { next[0] = reach[0]; for j in 1..=n { next[j] = reach[j] || next[j - 1]; } } } reach = next; } reach[n] } fn is_literal(pattern: &str) -> bool { !pattern.contains('*') } /// Rules for `branch` (short name, without refs/heads/): /// 1. If any rule matches: the most specific one wins. A literal (no `*`) beats a glob; among globs the /// longest pattern wins, and ties go to the earliest created_at. /// 2. Otherwise, if `branch == default_branch`: built-in protection {force_push: false, deletion: false}. /// 3. Otherwise: unprotected {force_push: true, deletion: true}. pub fn effective(rules: &[BranchRule], default_branch: &str, branch: &str) -> Protection { let best = rules .iter() .filter(|r| pattern_matches(&r.pattern, branch)) .min_by(|a, b| { // Literal first, then longer pattern, then earliest creation, then pattern text // so the result never depends on the order of `rules`. is_literal(&b.pattern) .cmp(&is_literal(&a.pattern)) .then_with(|| b.pattern.len().cmp(&a.pattern.len())) .then_with(|| a.created_at.cmp(&b.created_at)) .then_with(|| a.pattern.cmp(&b.pattern)) }); if let Some(rule) = best { return Protection { allow_force_push: rule.allow_force_push, allow_deletion: rule.allow_deletion, required_approvals: rule.required_approvals, block_on_changes_requested: rule.block_on_changes_requested, require_up_to_date: rule.require_up_to_date, restrict_push: rule.restrict_push, }; } if branch == default_branch { Protection { allow_force_push: false, allow_deletion: false, ..Protection::default() } } else { Protection { allow_force_push: true, allow_deletion: true, ..Protection::default() } } } #[cfg(test)] mod tests { use super::*; fn rule(pattern: &str, force: bool, deletion: bool, created: &str) -> BranchRule { BranchRule { id: format!("id-{pattern}"), repo_id: "r".into(), pattern: pattern.into(), allow_force_push: force, allow_deletion: deletion, required_approvals: 0, block_on_changes_requested: false, require_up_to_date: false, restrict_push: false, created_at: created.into(), } } fn flags(force: bool, deletion: bool) -> Protection { Protection { allow_force_push: force, allow_deletion: deletion, ..Protection::default() } } #[test] fn pattern_matches_cases() { assert!(pattern_matches("main", "main")); assert!(!pattern_matches("main", "main2")); assert!(pattern_matches("release/*", "release/1.0")); assert!(!pattern_matches("release/*", "release/a/b")); assert!(pattern_matches("release/**", "release/a/b")); assert!(pattern_matches("*", "feature")); assert!(!pattern_matches("*", "a/b")); assert!(pattern_matches("**", "anything/at/all")); assert!(pattern_matches("**", "x")); assert!(!pattern_matches("", "x")); assert!(pattern_matches("a*b", "ab")); assert!(pattern_matches("a*b", "axxb")); assert!(!pattern_matches("a*b", "a/b")); } #[test] fn validate_pattern_rejects_bad_input() { for bad in [ "refs/heads/main", "a..b", "-x", "a b", "a~b", "", &"a".repeat(201), ] { assert!(validate_pattern(bad).is_err(), "{bad:?} should be rejected"); } } #[test] fn validate_pattern_accepts_good_input() { for good in [ "main", "release/*", "release/**", "**", "a".repeat(200).as_str(), ] { assert!( validate_pattern(good).is_ok(), "{good:?} should be accepted" ); } } #[test] fn effective_without_rules_protects_only_default() { let none: &[BranchRule] = &[]; assert_eq!(effective(none, "main", "main"), flags(false, false)); assert_eq!(effective(none, "main", "feature"), flags(true, true)); } #[test] fn effective_literal_allows_force_push_on_main() { let rules = [rule("main", true, false, "1")]; assert_eq!(effective(&rules, "main", "main"), flags(true, false)); } #[test] fn effective_literal_beats_glob() { let rules = [rule("*", false, false, "1"), rule("main", true, true, "2")]; assert_eq!(effective(&rules, "main", "main"), flags(true, true)); } #[test] fn effective_longer_glob_wins() { // Both `*` and `rel*` match `release`; the longer `rel*` wins even though `*` is older. let rules = [rule("*", true, true, "0"), rule("rel*", false, false, "1")]; assert_eq!(effective(&rules, "main", "release"), flags(false, false)); // `release/**` is longer than `release/*`, so it wins for nested names. let rules = [ rule("release/*", true, true, "1"), rule("release/**", false, false, "2"), ]; assert_eq!( effective(&rules, "main", "release/a/b"), flags(false, false) ); } #[test] fn effective_carries_merge_and_push_rules() { let mut gated = rule("main", false, false, "1"); gated.required_approvals = 2; gated.block_on_changes_requested = true; gated.require_up_to_date = true; gated.restrict_push = true; let rules = [gated]; assert_eq!( effective(&rules, "main", "main"), Protection { allow_force_push: false, allow_deletion: false, required_approvals: 2, block_on_changes_requested: true, require_up_to_date: true, restrict_push: true, } ); // Unprotected and built-in default branches carry no merge rules. assert_eq!(effective(&rules, "main", "feature").required_approvals, 0); assert!(!effective(&[], "main", "main").restrict_push); } #[test] fn validate_required_approvals_bounds() { assert!(validate_required_approvals(0).is_ok()); assert!(validate_required_approvals(MAX_REQUIRED_APPROVALS).is_ok()); assert!(validate_required_approvals(MAX_REQUIRED_APPROVALS + 1).is_err()); } #[test] fn effective_double_star_deletion_false_protects_every_branch() { let rules = [rule("**", true, false, "1")]; for branch in ["main", "feature", "a/b/c"] { assert!( !effective(&rules, "main", branch).allow_deletion, "{branch} should not allow deletion" ); } } }