forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
20.8 KiB
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
15use std::collections::{BTreeMap, BTreeSet};
16
17use globset::{Glob, GlobBuilder};
18use serde::{Deserialize, Serialize};
19
20use crate::error::ConfigError;
21
22/// Largest accepted `ci.toml`, in bytes.
23pub const MAX_CONFIG_BYTES: usize = 64 * 1024;
24/// Largest number of jobs in one workflow.
25pub const MAX_JOBS: usize = 20;
26/// Default job timeout.
27pub const DEFAULT_TIMEOUT_MINUTES: u32 = 60;
28/// Smallest and largest allowed job timeout.
29pub 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)]
34pub 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)]
46pub 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
56impl 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)]
70pub 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
92fn default_timeout_minutes() -> u32 {
93 DEFAULT_TIMEOUT_MINUTES
94}
95
96fn default_network() -> bool {
97 true
98}
99
100/// Something that can start a workflow run.
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub 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.
110pub 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
122impl 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
194fn 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
200fn build_glob(pattern: &str) -> Result<Glob, globset::Error> {
201 GlobBuilder::new(pattern).literal_separator(true).build()
202}
203
204fn 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}$`
219fn 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.
231fn 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
242fn 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
249fn 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
269fn 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)]
332mod tests {
333 use super::*;
334
335 const EXAMPLE: &str = r#"
336[on]
337push = ["main"]
338pull_request = true
339
340[jobs.test]
341image = "docker.io/rust:1"
342run = "cargo test --workspace"
343
344[jobs.lint]
345image = "docker.io/rust:1"
346needs = ["test"]
347secrets = ["DEPLOY_KEY"]
348run = '''
349cargo clippy -- -D warnings
350cargo fmt --check
351'''
352artifacts = ["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}