//! Parser and validator for `.forge/ci.toml`. //! //! ```toml //! [on] //! push = ["main"] //! pull_request = true //! //! [jobs.test] //! image = "docker.io/rust:1" //! run = "cargo test --workspace" //! ``` //! //! Globs (`push`, `tags`, `artifacts`) use git-like semantics: `*` does not match `/`, `**` does. use std::collections::{BTreeMap, BTreeSet}; use globset::{Glob, GlobBuilder}; use serde::{Deserialize, Serialize}; use crate::error::ConfigError; /// Largest accepted `ci.toml`, in bytes. pub const MAX_CONFIG_BYTES: usize = 64 * 1024; /// Largest number of jobs in one workflow. pub const MAX_JOBS: usize = 20; /// Default job timeout. pub const DEFAULT_TIMEOUT_MINUTES: u32 = 60; /// Smallest and largest allowed job timeout. pub const TIMEOUT_RANGE_MINUTES: std::ops::RangeInclusive = 1..=360; /// A parsed and validated workflow. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct CiConfig { /// The `[on]` table. Optional; defaults to manual runs only. #[serde(rename = "on", default)] pub on: Triggers, /// The `[jobs.]` tables, keyed by job name. #[serde(default)] pub jobs: BTreeMap, } /// The `[on]` table. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields, default)] pub struct Triggers { /// Branch globs, e.g. `"main"`, `"release/*"`. pub push: Vec, /// Tag globs, e.g. `"v*"`. pub tags: Vec, pub pull_request: bool, /// Defaults to `true`. pub manual: bool, } impl Default for Triggers { fn default() -> Self { Self { push: Vec::new(), tags: Vec::new(), pull_request: false, manual: true, } } } /// One `[jobs.]` table. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct JobSpec { pub image: String, pub run: String, #[serde(default)] pub needs: Vec, /// Secret names (`^[A-Z_][A-Z0-9_]{0,63}$`) the job receives. #[serde(default)] pub secrets: Vec, #[serde(default)] pub env: BTreeMap, /// Relative globs of files to keep after a successful run. #[serde(default)] pub artifacts: Vec, /// Runner labels; every label must be present on the runner. #[serde(default)] pub runs_on: Vec, #[serde(default = "default_timeout_minutes")] pub timeout_minutes: u32, #[serde(default = "default_network")] pub network: bool, } fn default_timeout_minutes() -> u32 { DEFAULT_TIMEOUT_MINUTES } fn default_network() -> bool { true } /// Something that can start a workflow run. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Event<'a> { Push { branch: &'a str }, Tag { tag: &'a str }, PullRequest, Manual, } /// Parses and validates a `ci.toml` document. pub fn parse_config(text: &str) -> Result { if text.len() > MAX_CONFIG_BYTES { return Err(ConfigError::TooLarge { size: text.len(), max: MAX_CONFIG_BYTES, }); } let config: CiConfig = toml::from_str(text).map_err(|e| ConfigError::Toml(e.to_string()))?; config.validate()?; Ok(config) } impl CiConfig { /// Whether an event starts this workflow. pub fn triggered_by(&self, ev: &Event) -> bool { match ev { Event::Push { branch } => matches_any(&self.on.push, branch), Event::Tag { tag } => matches_any(&self.on.tags, tag), Event::PullRequest => self.on.pull_request, Event::Manual => self.on.manual, } } /// Job names in dependency order. Ties are broken by name, so the order is stable. /// Jobs in a cycle, or with unknown needs, are left out. [`parse_config`] rejects both. pub fn job_order(&self) -> Vec<&str> { let mut remaining: BTreeMap<&str, BTreeSet<&str>> = self .jobs .iter() .map(|(name, job)| { ( name.as_str(), job.needs.iter().map(String::as_str).collect(), ) }) .collect(); let mut done: BTreeSet<&str> = BTreeSet::new(); let mut order = Vec::with_capacity(self.jobs.len()); loop { let next = remaining .iter() .find(|(_, needs)| needs.iter().all(|n| done.contains(n))) .map(|(name, _)| *name); let Some(name) = next else { break }; remaining.remove(name); done.insert(name); order.push(name); } order } fn validate(&self) -> Result<(), ConfigError> { if self.jobs.is_empty() { return Err(ConfigError::NoJobs); } if self.jobs.len() > MAX_JOBS { return Err(ConfigError::TooManyJobs { count: self.jobs.len(), max: MAX_JOBS, }); } for pattern in &self.on.push { validate_trigger_glob("branch", pattern)?; } for pattern in &self.on.tags { validate_trigger_glob("tag", pattern)?; } for (name, job) in &self.jobs { validate_job(name, job, &self.jobs)?; } let order = self.job_order(); if order.len() != self.jobs.len() { let job = self .jobs .keys() .find(|name| !order.contains(&name.as_str())) .cloned() .unwrap_or_default(); return Err(ConfigError::Cycle { job }); } Ok(()) } } fn matches_any(globs: &[String], value: &str) -> bool { globs .iter() .any(|pattern| build_glob(pattern).is_ok_and(|g| g.compile_matcher().is_match(value))) } fn build_glob(pattern: &str) -> Result { GlobBuilder::new(pattern).literal_separator(true).build() } fn validate_trigger_glob(kind: &'static str, pattern: &str) -> Result<(), ConfigError> { let invalid = |reason: String| ConfigError::InvalidTriggerGlob { kind, pattern: pattern.to_string(), reason, }; if pattern.is_empty() { return Err(invalid("pattern is empty".into())); } build_glob(pattern) .map(|_| ()) .map_err(|e| invalid(e.to_string())) } /// `^[a-z0-9][a-z0-9_-]{0,39}$` fn is_job_name(name: &str) -> bool { let Some((first, rest)) = name.as_bytes().split_first() else { return false; }; rest.len() <= 39 && (first.is_ascii_lowercase() || first.is_ascii_digit()) && rest .iter() .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || *b == b'_' || *b == b'-') } /// `^[A-Z_][A-Z0-9_]{0,63}$`, used for secret and env names. fn is_env_name(name: &str) -> bool { let Some((first, rest)) = name.as_bytes().split_first() else { return false; }; rest.len() <= 63 && (first.is_ascii_uppercase() || *first == b'_') && rest .iter() .all(|b| b.is_ascii_uppercase() || b.is_ascii_digit() || *b == b'_') } fn is_runner_label(label: &str) -> bool { (1..=64).contains(&label.len()) && label .bytes() .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b == b'.') } fn validate_artifact(job: &str, pattern: &str) -> Result<(), ConfigError> { let invalid = |reason: &str| ConfigError::InvalidArtifactGlob { job: job.to_string(), pattern: pattern.to_string(), reason: reason.to_string(), }; if pattern.is_empty() { return Err(invalid("pattern is empty")); } if pattern.starts_with('/') || pattern.starts_with('\\') { return Err(invalid("must be relative")); } if pattern.split(['/', '\\']).any(|part| part == "..") { return Err(invalid("must not contain \"..\"")); } build_glob(pattern) .map(|_| ()) .map_err(|e| invalid(&e.to_string())) } fn validate_job( name: &str, job: &JobSpec, all: &BTreeMap, ) -> Result<(), ConfigError> { if !is_job_name(name) { return Err(ConfigError::InvalidJobName(name.to_string())); } if job.image.is_empty() || job.image.chars().any(char::is_whitespace) { return Err(ConfigError::InvalidImage { job: name.to_string(), }); } if job.run.trim().is_empty() { return Err(ConfigError::EmptyRun { job: name.to_string(), }); } for need in &job.needs { if !all.contains_key(need) { return Err(ConfigError::UnknownNeed { job: name.to_string(), need: need.clone(), }); } } for secret in &job.secrets { if !is_env_name(secret) { return Err(ConfigError::InvalidSecretName { job: name.to_string(), name: secret.clone(), }); } } for key in job.env.keys() { if !is_env_name(key) { return Err(ConfigError::InvalidEnvName { job: name.to_string(), name: key.clone(), }); } } for pattern in &job.artifacts { validate_artifact(name, pattern)?; } for label in &job.runs_on { if !is_runner_label(label) { return Err(ConfigError::InvalidRunsOn { job: name.to_string(), label: label.clone(), }); } } if !TIMEOUT_RANGE_MINUTES.contains(&job.timeout_minutes) { return Err(ConfigError::InvalidTimeout { job: name.to_string(), value: job.timeout_minutes, }); } Ok(()) } #[cfg(test)] mod tests { use super::*; const EXAMPLE: &str = r#" [on] push = ["main"] pull_request = true [jobs.test] image = "docker.io/rust:1" run = "cargo test --workspace" [jobs.lint] image = "docker.io/rust:1" needs = ["test"] secrets = ["DEPLOY_KEY"] run = ''' cargo clippy -- -D warnings cargo fmt --check ''' artifacts = ["target/release/forge"] "#; fn job_toml(body: &str) -> String { format!("[jobs.build]\nimage = \"docker.io/rust:1\"\nrun = \"cargo build\"\n{body}\n") } fn err_of(text: &str) -> ConfigError { match parse_config(text) { Ok(_) => panic!("expected an error for:\n{text}"), Err(e) => e, } } #[test] fn parses_the_contract_example() { let cfg = parse_config(EXAMPLE).unwrap(); assert_eq!(cfg.on.push, vec!["main".to_string()]); assert!(cfg.on.pull_request); assert!(cfg.on.manual, "manual defaults to true"); assert!(cfg.on.tags.is_empty()); assert_eq!(cfg.jobs.len(), 2); let lint = &cfg.jobs["lint"]; assert_eq!(lint.needs, vec!["test".to_string()]); assert_eq!(lint.secrets, vec!["DEPLOY_KEY".to_string()]); assert_eq!(lint.artifacts, vec!["target/release/forge".to_string()]); assert!(lint.run.contains("cargo clippy -- -D warnings")); assert_eq!(lint.timeout_minutes, 60); assert!(lint.network); assert!(lint.runs_on.is_empty()); assert!(lint.env.is_empty()); } #[test] fn manual_defaults_to_true_and_on_is_optional() { let cfg = parse_config(&job_toml("")).unwrap(); assert!(cfg.on.manual); assert!(!cfg.on.pull_request); assert!(cfg.on.push.is_empty()); } #[test] fn manual_can_be_disabled() { let cfg = parse_config(&format!("[on]\nmanual = false\n{}", job_toml(""))).unwrap(); assert!(!cfg.triggered_by(&Event::Manual)); } #[test] fn unknown_keys_are_rejected() { assert!(matches!( err_of(&format!("[on]\nschedule = true\n{}", job_toml(""))), ConfigError::Toml(_) )); assert!(matches!( err_of(&job_toml("bogus = 1")), ConfigError::Toml(_) )); assert!(matches!(err_of("[other]\nx = 1\n"), ConfigError::Toml(_))); } #[test] fn no_jobs_is_an_error() { assert_eq!(err_of("[on]\nmanual = true\n"), ConfigError::NoJobs); } #[test] fn too_many_jobs_is_an_error() { let mut text = String::new(); for i in 0..=MAX_JOBS { text.push_str(&format!("[jobs.j{i}]\nimage = \"img\"\nrun = \"true\"\n")); } assert_eq!( err_of(&text), ConfigError::TooManyJobs { count: MAX_JOBS + 1, max: MAX_JOBS } ); let mut ok = String::new(); for i in 0..MAX_JOBS { ok.push_str(&format!("[jobs.j{i}]\nimage = \"img\"\nrun = \"true\"\n")); } assert!(parse_config(&ok).is_ok()); } #[test] fn file_size_cap() { let mut text = job_toml(""); text.push_str(&"#".repeat(MAX_CONFIG_BYTES)); assert!(matches!( err_of(&text), ConfigError::TooLarge { max, .. } if max == MAX_CONFIG_BYTES )); // Exactly at the limit is accepted. let mut at_limit = job_toml(""); let pad = MAX_CONFIG_BYTES - at_limit.len(); at_limit.push_str(&"#".repeat(pad)); assert_eq!(at_limit.len(), MAX_CONFIG_BYTES); assert!(parse_config(&at_limit).is_ok()); } #[test] fn invalid_job_names() { for name in ["Test", "-x", "_x", "a b", "a.b", ""] { let text = format!("[jobs.\"{name}\"]\nimage = \"img\"\nrun = \"true\"\n"); assert!( matches!(err_of(&text), ConfigError::InvalidJobName(_)), "name {name:?} should be rejected" ); } let long = "a".repeat(41); let text = format!("[jobs.{long}]\nimage = \"img\"\nrun = \"true\"\n"); assert!(matches!(err_of(&text), ConfigError::InvalidJobName(_))); let max = "a".repeat(40); let text = format!("[jobs.{max}]\nimage = \"img\"\nrun = \"true\"\n"); assert!(parse_config(&text).is_ok()); for name in ["a", "0", "a-b_c9"] { let text = format!("[jobs.{name}]\nimage = \"img\"\nrun = \"true\"\n"); assert!( parse_config(&text).is_ok(), "name {name:?} should be accepted" ); } } #[test] fn unknown_need() { let text = job_toml("needs = [\"ghost\"]"); assert_eq!( err_of(&text), ConfigError::UnknownNeed { job: "build".into(), need: "ghost".into() } ); } #[test] fn cycles_are_detected() { let text = "[jobs.a]\nimage = \"i\"\nrun = \"r\"\nneeds = [\"b\"]\n\ [jobs.b]\nimage = \"i\"\nrun = \"r\"\nneeds = [\"a\"]\n\ [jobs.c]\nimage = \"i\"\nrun = \"r\"\n"; assert_eq!(err_of(text), ConfigError::Cycle { job: "a".into() }); let self_loop = job_toml("needs = [\"build\"]"); assert_eq!( err_of(&self_loop), ConfigError::Cycle { job: "build".into() } ); } #[test] fn image_validation() { let empty = "[jobs.b]\nimage = \"\"\nrun = \"r\"\n"; assert!(matches!(err_of(empty), ConfigError::InvalidImage { .. })); let space = "[jobs.b]\nimage = \"a b\"\nrun = \"r\"\n"; assert!(matches!(err_of(space), ConfigError::InvalidImage { .. })); } #[test] fn run_validation() { let blank = "[jobs.b]\nimage = \"i\"\nrun = \" \\n \"\n"; assert!(matches!(err_of(blank), ConfigError::EmptyRun { .. })); } #[test] fn secret_and_env_names() { let long = "A".repeat(65); for bad in ["deploy", "1KEY", "A-B", "", long.as_str()] { let text = job_toml(&format!("secrets = [{bad:?}]")); assert!( matches!(err_of(&text), ConfigError::InvalidSecretName { .. }), "secret {bad:?} should be rejected" ); } let text = job_toml("secrets = [\"_OK_1\"]"); assert!(parse_config(&text).is_ok()); let text = job_toml("[jobs.build.env]\n\"lower\" = \"x\""); assert!(matches!(err_of(&text), ConfigError::InvalidEnvName { .. })); let text = job_toml("[jobs.build.env]\nRUST_LOG = \"info\""); assert!(parse_config(&text).is_ok()); } #[test] fn artifact_validation() { for bad in ["/etc/passwd", "..", "a/../b", "\\\\x", "", "a/[b"] { let text = job_toml(&format!("artifacts = [{bad:?}]")); assert!( matches!(err_of(&text), ConfigError::InvalidArtifactGlob { .. }), "artifact {bad:?} should be rejected" ); } let text = job_toml("artifacts = [\"target/release/*\", \"dist/**/*.tar\"]"); assert!(parse_config(&text).is_ok()); } #[test] fn runs_on_validation() { let text = job_toml("runs_on = [\"linux\", \"x86_64\"]"); assert_eq!( parse_config(&text).unwrap().jobs["build"].runs_on, vec!["linux".to_string(), "x86_64".to_string()] ); let text = job_toml("runs_on = [\"has space\"]"); assert!(matches!(err_of(&text), ConfigError::InvalidRunsOn { .. })); let text = job_toml("runs_on = [\"\"]"); assert!(matches!(err_of(&text), ConfigError::InvalidRunsOn { .. })); } #[test] fn timeout_validation() { assert_eq!( parse_config(&job_toml("timeout_minutes = 1")).unwrap().jobs["build"].timeout_minutes, 1 ); assert_eq!( parse_config(&job_toml("timeout_minutes = 360")) .unwrap() .jobs["build"] .timeout_minutes, 360 ); for bad in [0, 361] { let text = job_toml(&format!("timeout_minutes = {bad}")); assert_eq!( err_of(&text), ConfigError::InvalidTimeout { job: "build".into(), value: bad } ); } } #[test] fn network_defaults_to_true() { assert!(parse_config(&job_toml("")).unwrap().jobs["build"].network); assert!(!parse_config(&job_toml("network = false")).unwrap().jobs["build"].network); } #[test] fn branch_glob_triggers() { let text = format!("[on]\npush = [\"main\", \"release/*\"]\n{}", job_toml("")); let cfg = parse_config(&text).unwrap(); assert!(cfg.triggered_by(&Event::Push { branch: "main" })); assert!(cfg.triggered_by(&Event::Push { branch: "release/1.0" })); assert!(!cfg.triggered_by(&Event::Push { branch: "release/1.0/x" })); assert!(!cfg.triggered_by(&Event::Push { branch: "mainx" })); assert!(!cfg.triggered_by(&Event::Push { branch: "feature" })); assert!(!cfg.triggered_by(&Event::Tag { tag: "v1" })); } #[test] fn tag_glob_triggers() { let text = format!("[on]\ntags = [\"v*\"]\n{}", job_toml("")); let cfg = parse_config(&text).unwrap(); assert!(cfg.triggered_by(&Event::Tag { tag: "v1.2.3" })); assert!(!cfg.triggered_by(&Event::Tag { tag: "release-1" })); assert!(!cfg.triggered_by(&Event::Push { branch: "v1" })); } #[test] fn invalid_trigger_globs_are_rejected() { let text = format!("[on]\npush = [\"[\"]\n{}", job_toml("")); assert!(matches!( err_of(&text), ConfigError::InvalidTriggerGlob { kind: "branch", .. } )); let text = format!("[on]\ntags = [\"\"]\n{}", job_toml("")); assert!(matches!( err_of(&text), ConfigError::InvalidTriggerGlob { kind: "tag", .. } )); } #[test] fn pull_request_and_manual_events() { let cfg = parse_config(EXAMPLE).unwrap(); assert!(cfg.triggered_by(&Event::PullRequest)); assert!(cfg.triggered_by(&Event::Manual)); let cfg = parse_config(&job_toml("")).unwrap(); assert!(!cfg.triggered_by(&Event::PullRequest)); } #[test] fn job_order_is_topological_and_stable() { let text = "[jobs.lint]\nimage = \"i\"\nrun = \"r\"\nneeds = [\"test\"]\n\ [jobs.test]\nimage = \"i\"\nrun = \"r\"\n\ [jobs.docs]\nimage = \"i\"\nrun = \"r\"\n\ [jobs.deploy]\nimage = \"i\"\nrun = \"r\"\nneeds = [\"lint\", \"docs\"]\n"; let cfg = parse_config(text).unwrap(); assert_eq!(cfg.job_order(), vec!["docs", "test", "lint", "deploy"]); // Independent jobs come out in name order. let flat = parse_config( "[jobs.c]\nimage = \"i\"\nrun = \"r\"\n[jobs.a]\nimage = \"i\"\nrun = \"r\"\n[jobs.b]\nimage = \"i\"\nrun = \"r\"\n", ) .unwrap(); assert_eq!(flat.job_order(), vec!["a", "b", "c"]); } #[test] fn example_order() { let cfg = parse_config(EXAMPLE).unwrap(); assert_eq!(cfg.job_order(), vec!["test", "lint"]); } }