| 1 | //! Whether a ruleset can be saved, and the tidy form it is saved in. |
| 2 | |
| 3 | use g1t_contracts::access::RepoRole; |
| 4 | use g1t_contracts::rules::{ |
| 5 | ActorKind, AppliesTo, Level, MAX_APPROVALS, MAX_BYPASS_ACTORS, MAX_PATTERN_CHARS, MAX_PATTERNS, MAX_RULES, |
| 6 | PatternRule, Rule, RulesetSpec, Target, |
| 7 | }; |
| 8 | use g1t_contracts::time::parse_rfc3339; |
| 9 | |
| 10 | use crate::{glob, text, window}; |
| 11 | |
| 12 | /// The longest ruleset name. |
| 13 | pub const MAX_NAME_CHARS: usize = 100; |
| 14 | /// Required checks in one rule. |
| 15 | pub const MAX_CHECKS: usize = 50; |
| 16 | |
| 17 | fn tidy_list(list: &[String], what: &str) -> Result<Vec<String>, String> { |
| 18 | let mut out: Vec<String> = Vec::new(); |
| 19 | for item in list { |
| 20 | let item = item.trim(); |
| 21 | if item.is_empty() || out.iter().any(|have| have == item) { |
| 22 | continue; |
| 23 | } |
| 24 | if item.chars().count() > MAX_PATTERN_CHARS { |
| 25 | return Err(format!("{what} may be at most {MAX_PATTERN_CHARS} characters long.")); |
| 26 | } |
| 27 | if !glob::well_formed(item) { |
| 28 | return Err(format!("{what} {item} has a [ without a closing ].")); |
| 29 | } |
| 30 | out.push(item.to_owned()); |
| 31 | } |
| 32 | if out.len() > MAX_PATTERNS { |
| 33 | return Err(format!("A ruleset may list at most {MAX_PATTERNS} {what}s.")); |
| 34 | } |
| 35 | Ok(out) |
| 36 | } |
| 37 | |
| 38 | fn pattern(rule: &PatternRule, what: &str) -> Result<PatternRule, String> { |
| 39 | let tidy = PatternRule { name: rule.name.trim().to_owned(), pattern: rule.pattern.clone(), ..rule.clone() }; |
| 40 | if tidy.pattern.is_empty() { |
| 41 | return Err(format!("The {what} rule needs a pattern.")); |
| 42 | } |
| 43 | if tidy.pattern.chars().count() > MAX_PATTERN_CHARS { |
| 44 | return Err(format!("The {what} pattern may be at most {MAX_PATTERN_CHARS} characters long.")); |
| 45 | } |
| 46 | text::compile(&tidy).map_err(|why| format!("The {what} rule: {why}"))?; |
| 47 | Ok(tidy) |
| 48 | } |
| 49 | |
| 50 | fn period(period: &g1t_contracts::rules::Period, what: &str) -> Result<(), String> { |
| 51 | let start = parse_rfc3339(&period.start).ok_or_else(|| format!("A {what} needs a start time in RFC 3339, such as 2026-12-20T00:00:00Z."))?; |
| 52 | if let Some(end) = &period.end { |
| 53 | let end = parse_rfc3339(end).ok_or_else(|| format!("A {what}'s end must be an RFC 3339 time."))?; |
| 54 | if end <= start { |
| 55 | return Err(format!("A {what} must end after it starts.")); |
| 56 | } |
| 57 | } |
| 58 | Ok(()) |
| 59 | } |
| 60 | |
| 61 | fn rule(rule: &Rule, target: Target) -> Result<Rule, String> { |
| 62 | let label = rule.label(); |
| 63 | if target == Target::Tag && rule.for_branches_only() { |
| 64 | return Err(format!("{label} is a rule for branches, and this ruleset targets tags.")); |
| 65 | } |
| 66 | if target == Target::Branch && rule.for_tags_only() { |
| 67 | return Err(format!("{label} is a rule for tags, and this ruleset targets branches.")); |
| 68 | } |
| 69 | Ok(match rule { |
| 70 | Rule::PullRequest(params) => { |
| 71 | if params.required_approvals > MAX_APPROVALS { |
| 72 | return Err(format!("A pull request rule may require at most {MAX_APPROVALS} approvals.")); |
| 73 | } |
| 74 | let mut params = params.clone(); |
| 75 | params.allowed_merge_methods.dedup(); |
| 76 | Rule::PullRequest(params) |
| 77 | } |
| 78 | Rule::RequiredStatusChecks(params) => { |
| 79 | let mut params = params.clone(); |
| 80 | let mut checks = Vec::new(); |
| 81 | for check in ¶ms.checks { |
| 82 | let context = check.context.trim(); |
| 83 | if context.is_empty() || checks.iter().any(|have: &g1t_contracts::rules::RequiredCheck| have.context.eq_ignore_ascii_case(context)) { |
| 84 | continue; |
| 85 | } |
| 86 | if context.chars().count() > 200 { |
| 87 | return Err("A required check's name may be at most 200 characters long.".to_owned()); |
| 88 | } |
| 89 | checks.push(g1t_contracts::rules::RequiredCheck { context: context.to_owned(), integration: check.integration }); |
| 90 | } |
| 91 | if checks.len() > MAX_CHECKS { |
| 92 | return Err(format!("A ruleset may require at most {MAX_CHECKS} checks in one rule.")); |
| 93 | } |
| 94 | if checks.is_empty() && !params.strict { |
| 95 | return Err("Require status checks needs a check to require, or to require branches to be up to date.".to_owned()); |
| 96 | } |
| 97 | params.checks = checks; |
| 98 | params.paths = tidy_list(¶ms.paths, "path")?; |
| 99 | Rule::RequiredStatusChecks(params) |
| 100 | } |
| 101 | Rule::MergeQueue(params) => { |
| 102 | if !(1..=20).contains(¶ms.max_entries_to_build) { |
| 103 | return Err("The merge queue builds 1 to 20 pull requests at once.".to_owned()); |
| 104 | } |
| 105 | if params.min_entries_to_merge < 1 || params.min_entries_to_merge > params.max_entries_to_build { |
| 106 | return Err("The merge queue's smallest batch is between 1 and how many it builds at once.".to_owned()); |
| 107 | } |
| 108 | if params.min_entries_wait_minutes > 360 { |
| 109 | return Err("The merge queue waits at most 360 minutes for a batch to fill.".to_owned()); |
| 110 | } |
| 111 | if !(5..=360).contains(¶ms.check_response_timeout_minutes) { |
| 112 | return Err("The merge queue's check timeout is 5 to 360 minutes.".to_owned()); |
| 113 | } |
| 114 | Rule::MergeQueue(params.clone()) |
| 115 | } |
| 116 | Rule::RequiredDeployments(params) => { |
| 117 | let environments = tidy_list(¶ms.environments, "environment")?; |
| 118 | if environments.is_empty() { |
| 119 | return Err("Require deployments needs an environment, such as preview.".to_owned()); |
| 120 | } |
| 121 | Rule::RequiredDeployments(g1t_contracts::rules::DeploymentsRule { environments }) |
| 122 | } |
| 123 | Rule::CommitMessagePattern(params) => Rule::CommitMessagePattern(pattern(params, "commit message")?), |
| 124 | Rule::CommitAuthorEmailPattern(params) => Rule::CommitAuthorEmailPattern(pattern(params, "commit author email")?), |
| 125 | Rule::CommitterEmailPattern(params) => Rule::CommitterEmailPattern(pattern(params, "committer email")?), |
| 126 | Rule::BranchNamePattern(params) => Rule::BranchNamePattern(pattern(params, "branch name")?), |
| 127 | Rule::TagNamePattern(params) => Rule::TagNamePattern(pattern(params, "tag name")?), |
| 128 | Rule::FilePathRestriction(params) => { |
| 129 | let paths = tidy_list(¶ms.restricted_file_paths, "path")?; |
| 130 | if paths.is_empty() { |
| 131 | return Err("Restrict file paths needs a path pattern.".to_owned()); |
| 132 | } |
| 133 | Rule::FilePathRestriction(g1t_contracts::rules::FilePathRule { restricted_file_paths: paths }) |
| 134 | } |
| 135 | Rule::FileExtensionRestriction(params) => { |
| 136 | let extensions: Vec<String> = tidy_list(¶ms.restricted_file_extensions, "extension")? |
| 137 | .into_iter() |
| 138 | .map(|extension| { |
| 139 | let extension = extension.to_lowercase(); |
| 140 | if extension.starts_with('.') { extension } else { format!(".{extension}") } |
| 141 | }) |
| 142 | .collect(); |
| 143 | if extensions.is_empty() { |
| 144 | return Err("Restrict file extensions needs an extension, such as .exe.".to_owned()); |
| 145 | } |
| 146 | Rule::FileExtensionRestriction(g1t_contracts::rules::FileExtensionRule { restricted_file_extensions: extensions }) |
| 147 | } |
| 148 | Rule::MaxFileSize(params) => { |
| 149 | if !(1..=100).contains(¶ms.max_file_size_mb) { |
| 150 | return Err("The largest file allowed is 1 to 100 MB.".to_owned()); |
| 151 | } |
| 152 | Rule::MaxFileSize(params.clone()) |
| 153 | } |
| 154 | Rule::MaxFilePathLength(params) => { |
| 155 | if !(1..=4096).contains(¶ms.max_file_path_length) { |
| 156 | return Err("The longest path allowed is 1 to 4096 characters.".to_owned()); |
| 157 | } |
| 158 | Rule::MaxFilePathLength(params.clone()) |
| 159 | } |
| 160 | Rule::MaxFilesChanged(params) => { |
| 161 | if !(1..=100_000).contains(¶ms.max_files) { |
| 162 | return Err("The most files changed is 1 to 100000.".to_owned()); |
| 163 | } |
| 164 | Rule::MaxFilesChanged(params.clone()) |
| 165 | } |
| 166 | Rule::PathReview(params) => { |
| 167 | let paths = tidy_list(¶ms.paths, "path")?; |
| 168 | if paths.is_empty() { |
| 169 | return Err("Review for sensitive paths needs a path pattern.".to_owned()); |
| 170 | } |
| 171 | if !(1..=MAX_APPROVALS).contains(¶ms.required_approvals) { |
| 172 | return Err(format!("Review for sensitive paths asks for 1 to {MAX_APPROVALS} approvals.")); |
| 173 | } |
| 174 | let team = params |
| 175 | .team |
| 176 | .as_deref() |
| 177 | .map(|team| team.trim().trim_start_matches('@').to_lowercase()) |
| 178 | .filter(|team| !team.is_empty()); |
| 179 | Rule::PathReview(g1t_contracts::rules::PathReviewRule { paths, required_approvals: params.required_approvals, team }) |
| 180 | } |
| 181 | Rule::MergeWindow(params) => { |
| 182 | if window::offset_minutes(¶ms.time_zone).is_none() { |
| 183 | return Err("A merge window's time zone is an offset from UTC, such as +02:00, or UTC.".to_owned()); |
| 184 | } |
| 185 | for weekly in ¶ms.windows { |
| 186 | if weekly.days.is_empty() { |
| 187 | return Err("Each merge window needs at least one day.".to_owned()); |
| 188 | } |
| 189 | let (Some(start), Some(end)) = (window::clock(&weekly.start), window::clock(&weekly.end)) else { |
| 190 | return Err("A merge window's hours are HH:MM, such as 09:00 to 17:00.".to_owned()); |
| 191 | }; |
| 192 | if start == end { |
| 193 | return Err("A merge window must not start and end at the same time.".to_owned()); |
| 194 | } |
| 195 | } |
| 196 | for freeze in ¶ms.freezes { |
| 197 | period(freeze, "freeze")?; |
| 198 | } |
| 199 | for exception in ¶ms.exceptions { |
| 200 | period(exception, "exception")?; |
| 201 | } |
| 202 | if params.windows.is_empty() && params.freezes.is_empty() { |
| 203 | return Err("A merge window needs weekly hours or a freeze.".to_owned()); |
| 204 | } |
| 205 | Rule::MergeWindow(params.clone()) |
| 206 | } |
| 207 | other => other.clone(), |
| 208 | }) |
| 209 | } |
| 210 | |
| 211 | /// Rules that may appear more than once in a ruleset, each with its own |
| 212 | /// parameters. |
| 213 | fn repeatable(rule: &Rule) -> bool { |
| 214 | matches!( |
| 215 | rule, |
| 216 | Rule::RequiredStatusChecks(_) |
| 217 | | Rule::PathReview(_) |
| 218 | | Rule::CommitMessagePattern(_) |
| 219 | | Rule::CommitAuthorEmailPattern(_) |
| 220 | | Rule::CommitterEmailPattern(_) |
| 221 | | Rule::BranchNamePattern(_) |
| 222 | | Rule::TagNamePattern(_) |
| 223 | | Rule::FilePathRestriction(_) |
| 224 | ) |
| 225 | } |
| 226 | |
| 227 | /// The ruleset as it is saved, or why it cannot be. |
| 228 | pub fn validate(spec: &RulesetSpec, level: Level) -> Result<RulesetSpec, String> { |
| 229 | let name = spec.name.trim(); |
| 230 | if name.is_empty() { |
| 231 | return Err("Give the ruleset a name.".to_owned()); |
| 232 | } |
| 233 | if name.chars().count() > MAX_NAME_CHARS { |
| 234 | return Err(format!("A ruleset's name may be at most {MAX_NAME_CHARS} characters long.")); |
| 235 | } |
| 236 | let mut out = spec.clone(); |
| 237 | out.name = name.to_owned(); |
| 238 | out.conditions.ref_name.include = tidy_list(&spec.conditions.ref_name.include, "branch or tag pattern")?; |
| 239 | out.conditions.ref_name.exclude = tidy_list(&spec.conditions.ref_name.exclude, "branch or tag pattern")?; |
| 240 | if out.conditions.ref_name.include.is_empty() { |
| 241 | return Err(format!( |
| 242 | "Say which {}es it holds for: a pattern such as release/*, ~DEFAULT_BRANCH or ~ALL.", |
| 243 | if spec.target == Target::Tag { "tag" } else { "branch" } |
| 244 | )); |
| 245 | } |
| 246 | out.conditions.repository = match level { |
| 247 | Level::Repository => None, |
| 248 | Level::Workspace => { |
| 249 | let mut repository = spec.conditions.repository.clone().unwrap_or_default(); |
| 250 | repository.include = tidy_list(&repository.include, "repository pattern")?; |
| 251 | repository.exclude = tidy_list(&repository.exclude, "repository pattern")?; |
| 252 | repository.topics = repository |
| 253 | .topics |
| 254 | .iter() |
| 255 | .map(|topic| topic.trim().to_lowercase()) |
| 256 | .filter(|topic| !topic.is_empty()) |
| 257 | .collect(); |
| 258 | if repository.include.is_empty() { |
| 259 | return Err("Say which repositories it holds in: a name pattern, or ~ALL.".to_owned()); |
| 260 | } |
| 261 | Some(repository) |
| 262 | } |
| 263 | }; |
| 264 | if spec.bypass_actors.len() > MAX_BYPASS_ACTORS { |
| 265 | return Err(format!("A ruleset may list at most {MAX_BYPASS_ACTORS} bypass actors.")); |
| 266 | } |
| 267 | let mut bypass = Vec::new(); |
| 268 | for actor in &spec.bypass_actors { |
| 269 | let mut actor = actor.clone(); |
| 270 | actor.value = actor.value.trim().trim_start_matches('@').to_owned(); |
| 271 | match actor.kind { |
| 272 | ActorKind::Role => { |
| 273 | let role = actor.value.to_ascii_lowercase(); |
| 274 | if role != "owner" && RepoRole::parse(&role).is_none() { |
| 275 | return Err(format!("{} is not a role: use read, triage, write, maintain, admin or owner.", actor.value)); |
| 276 | } |
| 277 | actor.value = role; |
| 278 | } |
| 279 | ActorKind::G1t => actor.value.clear(), |
| 280 | _ if actor.value.is_empty() => return Err("Each bypass actor needs to say who.".to_owned()), |
| 281 | ActorKind::Team => actor.value = actor.value.to_lowercase(), |
| 282 | _ => {} |
| 283 | } |
| 284 | if !bypass.contains(&actor) { |
| 285 | bypass.push(actor); |
| 286 | } |
| 287 | } |
| 288 | out.bypass_actors = bypass; |
| 289 | if spec.rules.len() > MAX_RULES { |
| 290 | return Err(format!("A ruleset may have at most {MAX_RULES} rules.")); |
| 291 | } |
| 292 | let mut seen: Vec<(&'static str, AppliesTo)> = Vec::new(); |
| 293 | out.rules = Vec::new(); |
| 294 | for entry in &spec.rules { |
| 295 | let key = (entry.rule.kind(), entry.applies_to); |
| 296 | if !repeatable(&entry.rule) { |
| 297 | if seen.contains(&key) { |
| 298 | return Err(format!("{} appears twice for the same changes; keep one.", entry.rule.label())); |
| 299 | } |
| 300 | seen.push(key); |
| 301 | } |
| 302 | out.rules.push(g1t_contracts::rules::RuleEntry { rule: rule(&entry.rule, spec.target)?, applies_to: entry.applies_to }); |
| 303 | } |
| 304 | Ok(out) |
| 305 | } |
| 306 | |
| 307 | #[cfg(test)] |
| 308 | mod tests { |
| 309 | use super::*; |
| 310 | use g1t_contracts::rules::{ |
| 311 | BypassActor, BypassMode, Conditions, MergeQueueRule, MergeWindowRule, NoParameters, PatternOperator, |
| 312 | Period, RefCondition, RuleEntry, StatusChecksRule, WeeklyWindow, Weekday, |
| 313 | }; |
| 314 | |
| 315 | fn spec(rules: Vec<Rule>) -> RulesetSpec { |
| 316 | RulesetSpec { |
| 317 | name: " Protect main ".into(), |
| 318 | conditions: Conditions { |
| 319 | ref_name: RefCondition { include: vec!["~DEFAULT_BRANCH".into(), " ".into(), "~DEFAULT_BRANCH".into()], exclude: Vec::new() }, |
| 320 | repository: None, |
| 321 | }, |
| 322 | rules: rules.into_iter().map(RuleEntry::everyone).collect(), |
| 323 | ..RulesetSpec::default() |
| 324 | } |
| 325 | } |
| 326 | |
| 327 | #[test] |
| 328 | fn a_good_ruleset_is_tidied() { |
| 329 | let saved = validate(&spec(vec![Rule::Deletion(NoParameters {})]), Level::Repository).unwrap(); |
| 330 | assert_eq!(saved.name, "Protect main"); |
| 331 | assert_eq!(saved.conditions.ref_name.include, vec!["~DEFAULT_BRANCH"]); |
| 332 | assert!(saved.conditions.repository.is_none()); |
| 333 | let workspace = validate(&spec(vec![]), Level::Workspace).unwrap(); |
| 334 | assert_eq!(workspace.conditions.repository.unwrap().include, vec!["~ALL"]); |
| 335 | } |
| 336 | |
| 337 | #[test] |
| 338 | fn names_and_targets_are_required() { |
| 339 | let mut nameless = spec(vec![]); |
| 340 | nameless.name = " ".into(); |
| 341 | assert_eq!(validate(&nameless, Level::Repository).unwrap_err(), "Give the ruleset a name."); |
| 342 | let mut nowhere = spec(vec![]); |
| 343 | nowhere.conditions.ref_name.include.clear(); |
| 344 | assert!(validate(&nowhere, Level::Repository).unwrap_err().starts_with("Say which branches")); |
| 345 | let mut broken = spec(vec![]); |
| 346 | broken.conditions.ref_name.include = vec!["release/[0-9".into()]; |
| 347 | assert!(validate(&broken, Level::Repository).unwrap_err().contains("without a closing")); |
| 348 | } |
| 349 | |
| 350 | #[test] |
| 351 | fn rules_must_suit_the_target_and_appear_once() { |
| 352 | let mut tags = spec(vec![Rule::PullRequest(Default::default())]); |
| 353 | tags.target = Target::Tag; |
| 354 | assert_eq!( |
| 355 | validate(&tags, Level::Repository).unwrap_err(), |
| 356 | "Require a pull request before merging is a rule for branches, and this ruleset targets tags." |
| 357 | ); |
| 358 | let twice = spec(vec![Rule::Deletion(NoParameters {}), Rule::Deletion(NoParameters {})]); |
| 359 | assert!(validate(&twice, Level::Repository).unwrap_err().contains("appears twice")); |
| 360 | let mut for_each = spec(vec![Rule::Deletion(NoParameters {})]); |
| 361 | for_each.rules.push(RuleEntry { rule: Rule::Deletion(NoParameters {}), applies_to: AppliesTo::Agents }); |
| 362 | assert!(validate(&for_each, Level::Repository).is_ok(), "once for everyone, once for agents"); |
| 363 | } |
| 364 | |
| 365 | #[test] |
| 366 | fn parameters_are_checked() { |
| 367 | let bad_regex = spec(vec![Rule::CommitMessagePattern(PatternRule { |
| 368 | name: String::new(), |
| 369 | operator: PatternOperator::Regex, |
| 370 | pattern: "(".into(), |
| 371 | negate: false, |
| 372 | })]); |
| 373 | assert!(validate(&bad_regex, Level::Repository).unwrap_err().starts_with("The commit message rule: The regular expression is not valid")); |
| 374 | let queue = spec(vec![Rule::MergeQueue(MergeQueueRule { max_entries_to_build: 50, ..MergeQueueRule::default() })]); |
| 375 | assert!(validate(&queue, Level::Repository).is_err()); |
| 376 | let checks = spec(vec![Rule::RequiredStatusChecks(StatusChecksRule::default())]); |
| 377 | assert!(validate(&checks, Level::Repository).is_err()); |
| 378 | let window = spec(vec![Rule::MergeWindow(MergeWindowRule { |
| 379 | time_zone: "Mars/Olympus".into(), |
| 380 | windows: vec![WeeklyWindow { days: vec![Weekday::Mon], start: "09:00".into(), end: "17:00".into() }], |
| 381 | ..MergeWindowRule::default() |
| 382 | })]); |
| 383 | assert!(validate(&window, Level::Repository).unwrap_err().contains("time zone")); |
| 384 | let backwards = spec(vec![Rule::MergeWindow(MergeWindowRule { |
| 385 | freezes: vec![Period { start: "2026-12-20T00:00:00Z".into(), end: Some("2026-12-19T00:00:00Z".into()), reason: String::new() }], |
| 386 | ..MergeWindowRule::default() |
| 387 | })]); |
| 388 | assert_eq!(validate(&backwards, Level::Repository).unwrap_err(), "A freeze must end after it starts."); |
| 389 | } |
| 390 | |
| 391 | #[test] |
| 392 | fn bypass_actors_are_checked_and_tidied() { |
| 393 | let mut with = spec(vec![]); |
| 394 | with.bypass_actors = vec![ |
| 395 | BypassActor { kind: ActorKind::Role, value: "Admin".into(), mode: BypassMode::Always }, |
| 396 | BypassActor { kind: ActorKind::Team, value: "@Acme/Release".into(), mode: BypassMode::PullRequests }, |
| 397 | BypassActor { kind: ActorKind::G1t, value: "anything".into(), mode: BypassMode::Always }, |
| 398 | ]; |
| 399 | let saved = validate(&with, Level::Repository).unwrap(); |
| 400 | assert_eq!(saved.bypass_actors[0].value, "admin"); |
| 401 | assert_eq!(saved.bypass_actors[1].value, "acme/release"); |
| 402 | assert_eq!(saved.bypass_actors[2].value, ""); |
| 403 | with.bypass_actors = vec![BypassActor { kind: ActorKind::Role, value: "boss".into(), mode: BypassMode::Always }]; |
| 404 | assert!(validate(&with, Level::Repository).is_err()); |
| 405 | with.bypass_actors = vec![BypassActor { kind: ActorKind::User, value: " ".into(), mode: BypassMode::Always }]; |
| 406 | assert!(validate(&with, Level::Repository).is_err()); |
| 407 | } |
| 408 | } |