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