Tahinli/forge
public0 watching
★ 0
| 1 | //! Parser and validator for `.forge/ci.toml`. |
| 2 | //! |
| 3 | //! ```toml |
| 4 | //! [on] |
| 5 | //! push = ["main"] |
| 6 | //! pull_request = true |
| 7 | //! |
| 8 | //! [jobs.test] |
| 9 | //! image = "docker.io/rust:1" |
| 10 | //! run = "cargo test --workspace" |
| 11 | //! ``` |
| 12 | //! |
| 13 | //! Globs (`push`, `tags`, `artifacts`) use git-like semantics: `*` does not match `/`, `**` does. |
| 14 | |
| 15 | use std::collections::{BTreeMap, BTreeSet}; |
| 16 | |
| 17 | use globset::{Glob, GlobBuilder}; |
| 18 | use serde::{Deserialize, Serialize}; |
| 19 | |
| 20 | use crate::error::ConfigError; |
| 21 | |
| 22 | /// Largest accepted `ci.toml`, in bytes. |
| 23 | pub const MAX_CONFIG_BYTES: usize = 64 * 1024; |
| 24 | /// Largest number of jobs in one workflow. |
| 25 | pub const MAX_JOBS: usize = 20; |
| 26 | /// Default job timeout. |
| 27 | pub const DEFAULT_TIMEOUT_MINUTES: u32 = 60; |
| 28 | /// Smallest and largest allowed job timeout. |
| 29 | pub const TIMEOUT_RANGE_MINUTES: std::ops::RangeInclusive<u32> = 1..=360; |
| 30 | |
| 31 | /// A parsed and validated workflow. |
| 32 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] |
| 33 | #[serde(deny_unknown_fields)] |
| 34 | pub struct CiConfig { |
| 35 | /// The `[on]` table. Optional; defaults to manual runs only. |
| 36 | #[serde(rename = "on", default)] |
| 37 | pub on: Triggers, |
| 38 | /// The `[jobs.<name>]` tables, keyed by job name. |
| 39 | #[serde(default)] |
| 40 | pub jobs: BTreeMap<String, JobSpec>, |
| 41 | } |
| 42 | |
| 43 | /// The `[on]` table. |
| 44 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] |
| 45 | #[serde(deny_unknown_fields, default)] |
| 46 | pub struct Triggers { |
| 47 | /// Branch globs, e.g. `"main"`, `"release/*"`. |
| 48 | pub push: Vec<String>, |
| 49 | /// Tag globs, e.g. `"v*"`. |
| 50 | pub tags: Vec<String>, |
| 51 | pub pull_request: bool, |
| 52 | /// Defaults to `true`. |
| 53 | pub manual: bool, |
| 54 | } |
| 55 | |
| 56 | impl Default for Triggers { |
| 57 | fn default() -> Self { |
| 58 | Self { |
| 59 | push: Vec::new(), |
| 60 | tags: Vec::new(), |
| 61 | pull_request: false, |
| 62 | manual: true, |
| 63 | } |
| 64 | } |
| 65 | } |
| 66 | |
| 67 | /// One `[jobs.<name>]` table. |
| 68 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] |
| 69 | #[serde(deny_unknown_fields)] |
| 70 | pub struct JobSpec { |
| 71 | pub image: String, |
| 72 | pub run: String, |
| 73 | #[serde(default)] |
| 74 | pub needs: Vec<String>, |
| 75 | /// Secret names (`^[A-Z_][A-Z0-9_]{0,63}$`) the job receives. |
| 76 | #[serde(default)] |
| 77 | pub secrets: Vec<String>, |
| 78 | #[serde(default)] |
| 79 | pub env: BTreeMap<String, String>, |
| 80 | /// Relative globs of files to keep after a successful run. |
| 81 | #[serde(default)] |
| 82 | pub artifacts: Vec<String>, |
| 83 | /// Runner labels; every label must be present on the runner. |
| 84 | #[serde(default)] |
| 85 | pub runs_on: Vec<String>, |
| 86 | #[serde(default = "default_timeout_minutes")] |
| 87 | pub timeout_minutes: u32, |
| 88 | #[serde(default = "default_network")] |
| 89 | pub network: bool, |
| 90 | } |
| 91 | |
| 92 | fn default_timeout_minutes() -> u32 { |
| 93 | DEFAULT_TIMEOUT_MINUTES |
| 94 | } |
| 95 | |
| 96 | fn default_network() -> bool { |
| 97 | true |
| 98 | } |
| 99 | |
| 100 | /// Something that can start a workflow run. |
| 101 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 102 | pub enum Event<'a> { |
| 103 | Push { branch: &'a str }, |
| 104 | Tag { tag: &'a str }, |
| 105 | PullRequest, |
| 106 | Manual, |
| 107 | } |
| 108 | |
| 109 | /// Parses and validates a `ci.toml` document. |
| 110 | pub fn parse_config(text: &str) -> Result<CiConfig, ConfigError> { |
| 111 | if text.len() > MAX_CONFIG_BYTES { |
| 112 | return Err(ConfigError::TooLarge { |
| 113 | size: text.len(), |
| 114 | max: MAX_CONFIG_BYTES, |
| 115 | }); |
| 116 | } |
| 117 | let config: CiConfig = toml::from_str(text).map_err(|e| ConfigError::Toml(e.to_string()))?; |
| 118 | config.validate()?; |
| 119 | Ok(config) |
| 120 | } |
| 121 | |
| 122 | impl CiConfig { |
| 123 | /// Whether an event starts this workflow. |
| 124 | pub fn triggered_by(&self, ev: &Event) -> bool { |
| 125 | match ev { |
| 126 | Event::Push { branch } => matches_any(&self.on.push, branch), |
| 127 | Event::Tag { tag } => matches_any(&self.on.tags, tag), |
| 128 | Event::PullRequest => self.on.pull_request, |
| 129 | Event::Manual => self.on.manual, |
| 130 | } |
| 131 | } |
| 132 | |
| 133 | /// Job names in dependency order. Ties are broken by name, so the order is stable. |
| 134 | /// Jobs in a cycle, or with unknown needs, are left out. [`parse_config`] rejects both. |
| 135 | pub fn job_order(&self) -> Vec<&str> { |
| 136 | let mut remaining: BTreeMap<&str, BTreeSet<&str>> = self |
| 137 | .jobs |
| 138 | .iter() |
| 139 | .map(|(name, job)| { |
| 140 | ( |
| 141 | name.as_str(), |
| 142 | job.needs.iter().map(String::as_str).collect(), |
| 143 | ) |
| 144 | }) |
| 145 | .collect(); |
| 146 | let mut done: BTreeSet<&str> = BTreeSet::new(); |
| 147 | let mut order = Vec::with_capacity(self.jobs.len()); |
| 148 | loop { |
| 149 | let next = remaining |
| 150 | .iter() |
| 151 | .find(|(_, needs)| needs.iter().all(|n| done.contains(n))) |
| 152 | .map(|(name, _)| *name); |
| 153 | let Some(name) = next else { break }; |
| 154 | remaining.remove(name); |
| 155 | done.insert(name); |
| 156 | order.push(name); |
| 157 | } |
| 158 | order |
| 159 | } |
| 160 | |
| 161 | fn validate(&self) -> Result<(), ConfigError> { |
| 162 | if self.jobs.is_empty() { |
| 163 | return Err(ConfigError::NoJobs); |
| 164 | } |
| 165 | if self.jobs.len() > MAX_JOBS { |
| 166 | return Err(ConfigError::TooManyJobs { |
| 167 | count: self.jobs.len(), |
| 168 | max: MAX_JOBS, |
| 169 | }); |
| 170 | } |
| 171 | for pattern in &self.on.push { |
| 172 | validate_trigger_glob("branch", pattern)?; |
| 173 | } |
| 174 | for pattern in &self.on.tags { |
| 175 | validate_trigger_glob("tag", pattern)?; |
| 176 | } |
| 177 | for (name, job) in &self.jobs { |
| 178 | validate_job(name, job, &self.jobs)?; |
| 179 | } |
| 180 | let order = self.job_order(); |
| 181 | if order.len() != self.jobs.len() { |
| 182 | let job = self |
| 183 | .jobs |
| 184 | .keys() |
| 185 | .find(|name| !order.contains(&name.as_str())) |
| 186 | .cloned() |
| 187 | .unwrap_or_default(); |
| 188 | return Err(ConfigError::Cycle { job }); |
| 189 | } |
| 190 | Ok(()) |
| 191 | } |
| 192 | } |
| 193 | |
| 194 | fn matches_any(globs: &[String], value: &str) -> bool { |
| 195 | globs |
| 196 | .iter() |
| 197 | .any(|pattern| build_glob(pattern).is_ok_and(|g| g.compile_matcher().is_match(value))) |
| 198 | } |
| 199 | |
| 200 | fn build_glob(pattern: &str) -> Result<Glob, globset::Error> { |
| 201 | GlobBuilder::new(pattern).literal_separator(true).build() |
| 202 | } |
| 203 | |
| 204 | fn validate_trigger_glob(kind: &'static str, pattern: &str) -> Result<(), ConfigError> { |
| 205 | let invalid = |reason: String| ConfigError::InvalidTriggerGlob { |
| 206 | kind, |
| 207 | pattern: pattern.to_string(), |
| 208 | reason, |
| 209 | }; |
| 210 | if pattern.is_empty() { |
| 211 | return Err(invalid("pattern is empty".into())); |
| 212 | } |
| 213 | build_glob(pattern) |
| 214 | .map(|_| ()) |
| 215 | .map_err(|e| invalid(e.to_string())) |
| 216 | } |
| 217 | |
| 218 | /// `^[a-z0-9][a-z0-9_-]{0,39}$` |
| 219 | fn is_job_name(name: &str) -> bool { |
| 220 | let Some((first, rest)) = name.as_bytes().split_first() else { |
| 221 | return false; |
| 222 | }; |
| 223 | rest.len() <= 39 |
| 224 | && (first.is_ascii_lowercase() || first.is_ascii_digit()) |
| 225 | && rest |
| 226 | .iter() |
| 227 | .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || *b == b'_' || *b == b'-') |
| 228 | } |
| 229 | |
| 230 | /// `^[A-Z_][A-Z0-9_]{0,63}$`, used for secret and env names. |
| 231 | fn is_env_name(name: &str) -> bool { |
| 232 | let Some((first, rest)) = name.as_bytes().split_first() else { |
| 233 | return false; |
| 234 | }; |
| 235 | rest.len() <= 63 |
| 236 | && (first.is_ascii_uppercase() || *first == b'_') |
| 237 | && rest |
| 238 | .iter() |
| 239 | .all(|b| b.is_ascii_uppercase() || b.is_ascii_digit() || *b == b'_') |
| 240 | } |
| 241 | |
| 242 | fn is_runner_label(label: &str) -> bool { |
| 243 | (1..=64).contains(&label.len()) |
| 244 | && label |
| 245 | .bytes() |
| 246 | .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b == b'.') |
| 247 | } |
| 248 | |
| 249 | fn validate_artifact(job: &str, pattern: &str) -> Result<(), ConfigError> { |
| 250 | let invalid = |reason: &str| ConfigError::InvalidArtifactGlob { |
| 251 | job: job.to_string(), |
| 252 | pattern: pattern.to_string(), |
| 253 | reason: reason.to_string(), |
| 254 | }; |
| 255 | if pattern.is_empty() { |
| 256 | return Err(invalid("pattern is empty")); |
| 257 | } |
| 258 | if pattern.starts_with('/') || pattern.starts_with('\\') { |
| 259 | return Err(invalid("must be relative")); |
| 260 | } |
| 261 | if pattern.split(['/', '\\']).any(|part| part == "..") { |
| 262 | return Err(invalid("must not contain \"..\"")); |
| 263 | } |
| 264 | build_glob(pattern) |
| 265 | .map(|_| ()) |
| 266 | .map_err(|e| invalid(&e.to_string())) |
| 267 | } |
| 268 | |
| 269 | fn validate_job( |
| 270 | name: &str, |
| 271 | job: &JobSpec, |
| 272 | all: &BTreeMap<String, JobSpec>, |
| 273 | ) -> Result<(), ConfigError> { |
| 274 | if !is_job_name(name) { |
| 275 | return Err(ConfigError::InvalidJobName(name.to_string())); |
| 276 | } |
| 277 | if job.image.is_empty() || job.image.chars().any(char::is_whitespace) { |
| 278 | return Err(ConfigError::InvalidImage { |
| 279 | job: name.to_string(), |
| 280 | }); |
| 281 | } |
| 282 | if job.run.trim().is_empty() { |
| 283 | return Err(ConfigError::EmptyRun { |
| 284 | job: name.to_string(), |
| 285 | }); |
| 286 | } |
| 287 | for need in &job.needs { |
| 288 | if !all.contains_key(need) { |
| 289 | return Err(ConfigError::UnknownNeed { |
| 290 | job: name.to_string(), |
| 291 | need: need.clone(), |
| 292 | }); |
| 293 | } |
| 294 | } |
| 295 | for secret in &job.secrets { |
| 296 | if !is_env_name(secret) { |
| 297 | return Err(ConfigError::InvalidSecretName { |
| 298 | job: name.to_string(), |
| 299 | name: secret.clone(), |
| 300 | }); |
| 301 | } |
| 302 | } |
| 303 | for key in job.env.keys() { |
| 304 | if !is_env_name(key) { |
| 305 | return Err(ConfigError::InvalidEnvName { |
| 306 | job: name.to_string(), |
| 307 | name: key.clone(), |
| 308 | }); |
| 309 | } |
| 310 | } |
| 311 | for pattern in &job.artifacts { |
| 312 | validate_artifact(name, pattern)?; |
| 313 | } |
| 314 | for label in &job.runs_on { |
| 315 | if !is_runner_label(label) { |
| 316 | return Err(ConfigError::InvalidRunsOn { |
| 317 | job: name.to_string(), |
| 318 | label: label.clone(), |
| 319 | }); |
| 320 | } |
| 321 | } |
| 322 | if !TIMEOUT_RANGE_MINUTES.contains(&job.timeout_minutes) { |
| 323 | return Err(ConfigError::InvalidTimeout { |
| 324 | job: name.to_string(), |
| 325 | value: job.timeout_minutes, |
| 326 | }); |
| 327 | } |
| 328 | Ok(()) |
| 329 | } |
| 330 | |
| 331 | #[cfg(test)] |
| 332 | mod tests { |
| 333 | use super::*; |
| 334 | |
| 335 | const EXAMPLE: &str = r#" |
| 336 | [on] |
| 337 | push = ["main"] |
| 338 | pull_request = true |
| 339 | |
| 340 | [jobs.test] |
| 341 | image = "docker.io/rust:1" |
| 342 | run = "cargo test --workspace" |
| 343 | |
| 344 | [jobs.lint] |
| 345 | image = "docker.io/rust:1" |
| 346 | needs = ["test"] |
| 347 | secrets = ["DEPLOY_KEY"] |
| 348 | run = ''' |
| 349 | cargo clippy -- -D warnings |
| 350 | cargo fmt --check |
| 351 | ''' |
| 352 | artifacts = ["target/release/forge"] |
| 353 | "#; |
| 354 | |
| 355 | fn job_toml(body: &str) -> String { |
| 356 | format!("[jobs.build]\nimage = \"docker.io/rust:1\"\nrun = \"cargo build\"\n{body}\n") |
| 357 | } |
| 358 | |
| 359 | fn err_of(text: &str) -> ConfigError { |
| 360 | match parse_config(text) { |
| 361 | Ok(_) => panic!("expected an error for:\n{text}"), |
| 362 | Err(e) => e, |
| 363 | } |
| 364 | } |
| 365 | |
| 366 | #[test] |
| 367 | fn parses_the_contract_example() { |
| 368 | let cfg = parse_config(EXAMPLE).unwrap(); |
| 369 | assert_eq!(cfg.on.push, vec!["main".to_string()]); |
| 370 | assert!(cfg.on.pull_request); |
| 371 | assert!(cfg.on.manual, "manual defaults to true"); |
| 372 | assert!(cfg.on.tags.is_empty()); |
| 373 | assert_eq!(cfg.jobs.len(), 2); |
| 374 | let lint = &cfg.jobs["lint"]; |
| 375 | assert_eq!(lint.needs, vec!["test".to_string()]); |
| 376 | assert_eq!(lint.secrets, vec!["DEPLOY_KEY".to_string()]); |
| 377 | assert_eq!(lint.artifacts, vec!["target/release/forge".to_string()]); |
| 378 | assert!(lint.run.contains("cargo clippy -- -D warnings")); |
| 379 | assert_eq!(lint.timeout_minutes, 60); |
| 380 | assert!(lint.network); |
| 381 | assert!(lint.runs_on.is_empty()); |
| 382 | assert!(lint.env.is_empty()); |
| 383 | } |
| 384 | |
| 385 | #[test] |
| 386 | fn manual_defaults_to_true_and_on_is_optional() { |
| 387 | let cfg = parse_config(&job_toml("")).unwrap(); |
| 388 | assert!(cfg.on.manual); |
| 389 | assert!(!cfg.on.pull_request); |
| 390 | assert!(cfg.on.push.is_empty()); |
| 391 | } |
| 392 | |
| 393 | #[test] |
| 394 | fn manual_can_be_disabled() { |
| 395 | let cfg = parse_config(&format!("[on]\nmanual = false\n{}", job_toml(""))).unwrap(); |
| 396 | assert!(!cfg.triggered_by(&Event::Manual)); |
| 397 | } |
| 398 | |
| 399 | #[test] |
| 400 | fn unknown_keys_are_rejected() { |
| 401 | assert!(matches!( |
| 402 | err_of(&format!("[on]\nschedule = true\n{}", job_toml(""))), |
| 403 | ConfigError::Toml(_) |
| 404 | )); |
| 405 | assert!(matches!( |
| 406 | err_of(&job_toml("bogus = 1")), |
| 407 | ConfigError::Toml(_) |
| 408 | )); |
| 409 | assert!(matches!(err_of("[other]\nx = 1\n"), ConfigError::Toml(_))); |
| 410 | } |
| 411 | |
| 412 | #[test] |
| 413 | fn no_jobs_is_an_error() { |
| 414 | assert_eq!(err_of("[on]\nmanual = true\n"), ConfigError::NoJobs); |
| 415 | } |
| 416 | |
| 417 | #[test] |
| 418 | fn too_many_jobs_is_an_error() { |
| 419 | let mut text = String::new(); |
| 420 | for i in 0..=MAX_JOBS { |
| 421 | text.push_str(&format!("[jobs.j{i}]\nimage = \"img\"\nrun = \"true\"\n")); |
| 422 | } |
| 423 | assert_eq!( |
| 424 | err_of(&text), |
| 425 | ConfigError::TooManyJobs { |
| 426 | count: MAX_JOBS + 1, |
| 427 | max: MAX_JOBS |
| 428 | } |
| 429 | ); |
| 430 | let mut ok = String::new(); |
| 431 | for i in 0..MAX_JOBS { |
| 432 | ok.push_str(&format!("[jobs.j{i}]\nimage = \"img\"\nrun = \"true\"\n")); |
| 433 | } |
| 434 | assert!(parse_config(&ok).is_ok()); |
| 435 | } |
| 436 | |
| 437 | #[test] |
| 438 | fn file_size_cap() { |
| 439 | let mut text = job_toml(""); |
| 440 | text.push_str(&"#".repeat(MAX_CONFIG_BYTES)); |
| 441 | assert!(matches!( |
| 442 | err_of(&text), |
| 443 | ConfigError::TooLarge { max, .. } if max == MAX_CONFIG_BYTES |
| 444 | )); |
| 445 | // Exactly at the limit is accepted. |
| 446 | let mut at_limit = job_toml(""); |
| 447 | let pad = MAX_CONFIG_BYTES - at_limit.len(); |
| 448 | at_limit.push_str(&"#".repeat(pad)); |
| 449 | assert_eq!(at_limit.len(), MAX_CONFIG_BYTES); |
| 450 | assert!(parse_config(&at_limit).is_ok()); |
| 451 | } |
| 452 | |
| 453 | #[test] |
| 454 | fn invalid_job_names() { |
| 455 | for name in ["Test", "-x", "_x", "a b", "a.b", ""] { |
| 456 | let text = format!("[jobs.\"{name}\"]\nimage = \"img\"\nrun = \"true\"\n"); |
| 457 | assert!( |
| 458 | matches!(err_of(&text), ConfigError::InvalidJobName(_)), |
| 459 | "name {name:?} should be rejected" |
| 460 | ); |
| 461 | } |
| 462 | let long = "a".repeat(41); |
| 463 | let text = format!("[jobs.{long}]\nimage = \"img\"\nrun = \"true\"\n"); |
| 464 | assert!(matches!(err_of(&text), ConfigError::InvalidJobName(_))); |
| 465 | let max = "a".repeat(40); |
| 466 | let text = format!("[jobs.{max}]\nimage = \"img\"\nrun = \"true\"\n"); |
| 467 | assert!(parse_config(&text).is_ok()); |
| 468 | for name in ["a", "0", "a-b_c9"] { |
| 469 | let text = format!("[jobs.{name}]\nimage = \"img\"\nrun = \"true\"\n"); |
| 470 | assert!( |
| 471 | parse_config(&text).is_ok(), |
| 472 | "name {name:?} should be accepted" |
| 473 | ); |
| 474 | } |
| 475 | } |
| 476 | |
| 477 | #[test] |
| 478 | fn unknown_need() { |
| 479 | let text = job_toml("needs = [\"ghost\"]"); |
| 480 | assert_eq!( |
| 481 | err_of(&text), |
| 482 | ConfigError::UnknownNeed { |
| 483 | job: "build".into(), |
| 484 | need: "ghost".into() |
| 485 | } |
| 486 | ); |
| 487 | } |
| 488 | |
| 489 | #[test] |
| 490 | fn cycles_are_detected() { |
| 491 | let text = "[jobs.a]\nimage = \"i\"\nrun = \"r\"\nneeds = [\"b\"]\n\ |
| 492 | [jobs.b]\nimage = \"i\"\nrun = \"r\"\nneeds = [\"a\"]\n\ |
| 493 | [jobs.c]\nimage = \"i\"\nrun = \"r\"\n"; |
| 494 | assert_eq!(err_of(text), ConfigError::Cycle { job: "a".into() }); |
| 495 | let self_loop = job_toml("needs = [\"build\"]"); |
| 496 | assert_eq!( |
| 497 | err_of(&self_loop), |
| 498 | ConfigError::Cycle { |
| 499 | job: "build".into() |
| 500 | } |
| 501 | ); |
| 502 | } |
| 503 | |
| 504 | #[test] |
| 505 | fn image_validation() { |
| 506 | let empty = "[jobs.b]\nimage = \"\"\nrun = \"r\"\n"; |
| 507 | assert!(matches!(err_of(empty), ConfigError::InvalidImage { .. })); |
| 508 | let space = "[jobs.b]\nimage = \"a b\"\nrun = \"r\"\n"; |
| 509 | assert!(matches!(err_of(space), ConfigError::InvalidImage { .. })); |
| 510 | } |
| 511 | |
| 512 | #[test] |
| 513 | fn run_validation() { |
| 514 | let blank = "[jobs.b]\nimage = \"i\"\nrun = \" \\n \"\n"; |
| 515 | assert!(matches!(err_of(blank), ConfigError::EmptyRun { .. })); |
| 516 | } |
| 517 | |
| 518 | #[test] |
| 519 | fn secret_and_env_names() { |
| 520 | let long = "A".repeat(65); |
| 521 | for bad in ["deploy", "1KEY", "A-B", "", long.as_str()] { |
| 522 | let text = job_toml(&format!("secrets = [{bad:?}]")); |
| 523 | assert!( |
| 524 | matches!(err_of(&text), ConfigError::InvalidSecretName { .. }), |
| 525 | "secret {bad:?} should be rejected" |
| 526 | ); |
| 527 | } |
| 528 | let text = job_toml("secrets = [\"_OK_1\"]"); |
| 529 | assert!(parse_config(&text).is_ok()); |
| 530 | let text = job_toml("[jobs.build.env]\n\"lower\" = \"x\""); |
| 531 | assert!(matches!(err_of(&text), ConfigError::InvalidEnvName { .. })); |
| 532 | let text = job_toml("[jobs.build.env]\nRUST_LOG = \"info\""); |
| 533 | assert!(parse_config(&text).is_ok()); |
| 534 | } |
| 535 | |
| 536 | #[test] |
| 537 | fn artifact_validation() { |
| 538 | for bad in ["/etc/passwd", "..", "a/../b", "\\\\x", "", "a/[b"] { |
| 539 | let text = job_toml(&format!("artifacts = [{bad:?}]")); |
| 540 | assert!( |
| 541 | matches!(err_of(&text), ConfigError::InvalidArtifactGlob { .. }), |
| 542 | "artifact {bad:?} should be rejected" |
| 543 | ); |
| 544 | } |
| 545 | let text = job_toml("artifacts = [\"target/release/*\", \"dist/**/*.tar\"]"); |
| 546 | assert!(parse_config(&text).is_ok()); |
| 547 | } |
| 548 | |
| 549 | #[test] |
| 550 | fn runs_on_validation() { |
| 551 | let text = job_toml("runs_on = [\"linux\", \"x86_64\"]"); |
| 552 | assert_eq!( |
| 553 | parse_config(&text).unwrap().jobs["build"].runs_on, |
| 554 | vec!["linux".to_string(), "x86_64".to_string()] |
| 555 | ); |
| 556 | let text = job_toml("runs_on = [\"has space\"]"); |
| 557 | assert!(matches!(err_of(&text), ConfigError::InvalidRunsOn { .. })); |
| 558 | let text = job_toml("runs_on = [\"\"]"); |
| 559 | assert!(matches!(err_of(&text), ConfigError::InvalidRunsOn { .. })); |
| 560 | } |
| 561 | |
| 562 | #[test] |
| 563 | fn timeout_validation() { |
| 564 | assert_eq!( |
| 565 | parse_config(&job_toml("timeout_minutes = 1")).unwrap().jobs["build"].timeout_minutes, |
| 566 | 1 |
| 567 | ); |
| 568 | assert_eq!( |
| 569 | parse_config(&job_toml("timeout_minutes = 360")) |
| 570 | .unwrap() |
| 571 | .jobs["build"] |
| 572 | .timeout_minutes, |
| 573 | 360 |
| 574 | ); |
| 575 | for bad in [0, 361] { |
| 576 | let text = job_toml(&format!("timeout_minutes = {bad}")); |
| 577 | assert_eq!( |
| 578 | err_of(&text), |
| 579 | ConfigError::InvalidTimeout { |
| 580 | job: "build".into(), |
| 581 | value: bad |
| 582 | } |
| 583 | ); |
| 584 | } |
| 585 | } |
| 586 | |
| 587 | #[test] |
| 588 | fn network_defaults_to_true() { |
| 589 | assert!(parse_config(&job_toml("")).unwrap().jobs["build"].network); |
| 590 | assert!(!parse_config(&job_toml("network = false")).unwrap().jobs["build"].network); |
| 591 | } |
| 592 | |
| 593 | #[test] |
| 594 | fn branch_glob_triggers() { |
| 595 | let text = format!("[on]\npush = [\"main\", \"release/*\"]\n{}", job_toml("")); |
| 596 | let cfg = parse_config(&text).unwrap(); |
| 597 | assert!(cfg.triggered_by(&Event::Push { branch: "main" })); |
| 598 | assert!(cfg.triggered_by(&Event::Push { |
| 599 | branch: "release/1.0" |
| 600 | })); |
| 601 | assert!(!cfg.triggered_by(&Event::Push { |
| 602 | branch: "release/1.0/x" |
| 603 | })); |
| 604 | assert!(!cfg.triggered_by(&Event::Push { branch: "mainx" })); |
| 605 | assert!(!cfg.triggered_by(&Event::Push { branch: "feature" })); |
| 606 | assert!(!cfg.triggered_by(&Event::Tag { tag: "v1" })); |
| 607 | } |
| 608 | |
| 609 | #[test] |
| 610 | fn tag_glob_triggers() { |
| 611 | let text = format!("[on]\ntags = [\"v*\"]\n{}", job_toml("")); |
| 612 | let cfg = parse_config(&text).unwrap(); |
| 613 | assert!(cfg.triggered_by(&Event::Tag { tag: "v1.2.3" })); |
| 614 | assert!(!cfg.triggered_by(&Event::Tag { tag: "release-1" })); |
| 615 | assert!(!cfg.triggered_by(&Event::Push { branch: "v1" })); |
| 616 | } |
| 617 | |
| 618 | #[test] |
| 619 | fn invalid_trigger_globs_are_rejected() { |
| 620 | let text = format!("[on]\npush = [\"[\"]\n{}", job_toml("")); |
| 621 | assert!(matches!( |
| 622 | err_of(&text), |
| 623 | ConfigError::InvalidTriggerGlob { kind: "branch", .. } |
| 624 | )); |
| 625 | let text = format!("[on]\ntags = [\"\"]\n{}", job_toml("")); |
| 626 | assert!(matches!( |
| 627 | err_of(&text), |
| 628 | ConfigError::InvalidTriggerGlob { kind: "tag", .. } |
| 629 | )); |
| 630 | } |
| 631 | |
| 632 | #[test] |
| 633 | fn pull_request_and_manual_events() { |
| 634 | let cfg = parse_config(EXAMPLE).unwrap(); |
| 635 | assert!(cfg.triggered_by(&Event::PullRequest)); |
| 636 | assert!(cfg.triggered_by(&Event::Manual)); |
| 637 | let cfg = parse_config(&job_toml("")).unwrap(); |
| 638 | assert!(!cfg.triggered_by(&Event::PullRequest)); |
| 639 | } |
| 640 | |
| 641 | #[test] |
| 642 | fn job_order_is_topological_and_stable() { |
| 643 | let text = "[jobs.lint]\nimage = \"i\"\nrun = \"r\"\nneeds = [\"test\"]\n\ |
| 644 | [jobs.test]\nimage = \"i\"\nrun = \"r\"\n\ |
| 645 | [jobs.docs]\nimage = \"i\"\nrun = \"r\"\n\ |
| 646 | [jobs.deploy]\nimage = \"i\"\nrun = \"r\"\nneeds = [\"lint\", \"docs\"]\n"; |
| 647 | let cfg = parse_config(text).unwrap(); |
| 648 | assert_eq!(cfg.job_order(), vec!["docs", "test", "lint", "deploy"]); |
| 649 | // Independent jobs come out in name order. |
| 650 | let flat = parse_config( |
| 651 | "[jobs.c]\nimage = \"i\"\nrun = \"r\"\n[jobs.a]\nimage = \"i\"\nrun = \"r\"\n[jobs.b]\nimage = \"i\"\nrun = \"r\"\n", |
| 652 | ) |
| 653 | .unwrap(); |
| 654 | assert_eq!(flat.job_order(), vec!["a", "b", "c"]); |
| 655 | } |
| 656 | |
| 657 | #[test] |
| 658 | fn example_order() { |
| 659 | let cfg = parse_config(EXAMPLE).unwrap(); |
| 660 | assert_eq!(cfg.job_order(), vec!["test", "lint"]); |
| 661 | } |
| 662 | } |