zuka
zuka/src/ci/spec.rs

worklyn / zukapublic

Agent-first git hosting. One Rust binary: git over HTTP and SSH, a REST API, MCP, CI, and multi-tenant isolation.

Get a copy: git clone https://zuka.worklyn.com/worklyn/zuka.git
zuka/src/ci/spec.rs
RSspec.rs6.9 KBDownload
1// The in-repository CI spec.
2//
3// Kept deliberately small. A build description that needs its own manual is a
4// second thing to learn, and the point of this product is that there is nothing to
5// learn beyond git.
6//
7// There is no `runtime` key. An earlier version validated one against a list of
8// toolchains and then ignored it — every step ran under `/bin/sh` regardless — which
9// is worse than absent: a knob that appears to select a toolchain and does not.
10// Steps run with whatever is on the host `PATH`.
11
12use crate::brand;
13use crate::error::{Error, Result};
14use serde::{Deserialize, Serialize};
15use std::time::Duration;
16
17/// Longest a run may take regardless of what a repository asks for.
18pub const MAX_TIMEOUT: Duration = Duration::from_secs(3600);
19const DEFAULT_TIMEOUT_SECS: u64 = 600;
20/// Steps in one run. A spec longer than this is a program, not a build.
21const MAX_STEPS: usize = 50;
22const MAX_STEP_LEN: usize = 4096;
23const MAX_SPEC_BYTES: usize = 64 * 1024;
24
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
26pub struct Spec {
27 #[serde(default)]
28 pub run: Run,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32pub struct Run {
33 /// Shell commands, in order. A non-zero exit fails the run.
34 #[serde(default)]
35 pub steps: Vec<String>,
36 #[serde(default = "default_timeout")]
37 pub timeout_secs: u64,
38 /// Refs this runs on. Empty means every ref.
39 #[serde(default)]
40 pub branches: Vec<String>,
41}
42
43impl Default for Run {
44 fn default() -> Self {
45 Run {
46 steps: Vec::new(),
47 timeout_secs: default_timeout(),
48 branches: Vec::new(),
49 }
50 }
51}
52
53fn default_timeout() -> u64 {
54 DEFAULT_TIMEOUT_SECS
55}
56
57impl Spec {
58 /// Parse and validate a spec.
59 pub fn parse(source: &str) -> Result<Self> {
60 if source.len() > MAX_SPEC_BYTES {
61 return Err(Error::invalid(
62 "invalid-ci-spec",
63 format!("{} is too large", brand::ci_filename()),
64 ));
65 }
66
67 let spec: Spec = toml::from_str(source).map_err(|e| {
68 Error::invalid(
69 "invalid-ci-spec",
70 format!(
71 "{} is not valid TOML: {}",
72 brand::ci_filename(),
73 e.to_string().lines().next().unwrap_or("")
74 ),
75 )
76 })?;
77 spec.validate()?;
78 Ok(spec)
79 }
80
81 fn validate(&self) -> Result<()> {
82 if self.run.steps.is_empty() {
83 return Err(Error::invalid(
84 "invalid-ci-spec",
85 "run.steps must not be empty",
86 ));
87 }
88 if self.run.steps.len() > MAX_STEPS {
89 return Err(Error::invalid(
90 "invalid-ci-spec",
91 format!("run.steps must have at most {MAX_STEPS} entries"),
92 ));
93 }
94 for step in &self.run.steps {
95 if step.trim().is_empty() {
96 return Err(Error::invalid(
97 "invalid-ci-spec",
98 "a step must not be empty",
99 ));
100 }
101 if step.len() > MAX_STEP_LEN {
102 return Err(Error::invalid("invalid-ci-spec", "a step is too long"));
103 }
104 if step.contains('\0') {
105 return Err(Error::invalid(
106 "invalid-ci-spec",
107 "a step must not contain NUL",
108 ));
109 }
110 }
111 if self.run.timeout_secs == 0 {
112 return Err(Error::invalid(
113 "invalid-ci-spec",
114 "run.timeout_secs must be positive",
115 ));
116 }
117 for branch in &self.run.branches {
118 crate::git::validate::ref_name(branch)?;
119 }
120 Ok(())
121 }
122
123 /// Effective timeout, clamped to the host's ceiling.
124 ///
125 /// A repository may lower its own timeout but never raise it: otherwise one
126 /// repository can hold a runner slot for as long as it likes.
127 pub fn timeout(&self, ceiling: Duration) -> Duration {
128 Duration::from_secs(self.run.timeout_secs)
129 .min(ceiling)
130 .min(MAX_TIMEOUT)
131 }
132
133 /// Whether this spec runs for a given ref.
134 pub fn runs_for(&self, ref_name: &str) -> bool {
135 self.run.branches.is_empty() || self.run.branches.iter().any(|b| b == ref_name)
136 }
137}
138
139#[cfg(test)]
140mod tests {
141 use super::*;
142
143 #[test]
144 fn parses_a_minimal_spec() {
145 let spec = Spec::parse("[run]\nsteps = [\"cargo test\"]").unwrap();
146 assert_eq!(spec.run.steps, vec!["cargo test"]);
147 assert_eq!(spec.run.timeout_secs, DEFAULT_TIMEOUT_SECS);
148 }
149
150 #[test]
151 fn parses_a_full_spec() {
152 let spec = Spec::parse(
153 r#"
154 [run]
155 steps = ["cargo fmt --check", "cargo test"]
156 timeout_secs = 120
157 branches = ["refs/heads/main"]
158 "#,
159 )
160 .unwrap();
161 assert_eq!(spec.run.steps.len(), 2);
162 assert!(spec.runs_for("refs/heads/main"));
163 assert!(!spec.runs_for("refs/heads/topic"));
164 }
165
166 #[test]
167 fn a_spec_with_no_branches_runs_everywhere() {
168 let spec = Spec::parse("[run]\nsteps = [\"true\"]").unwrap();
169 assert!(spec.runs_for("refs/heads/anything"));
170 }
171
172 #[test]
173 fn rejects_a_spec_with_nothing_to_do() {
174 assert!(Spec::parse("[run]\nsteps = []").is_err());
175 assert!(Spec::parse("").is_err());
176 assert!(Spec::parse("[run]\nsteps = [\" \"]").is_err());
177 }
178
179 #[test]
180 fn rejects_malformed_toml_with_a_usable_message() {
181 assert_eq!(
182 Spec::parse("[run\nsteps =").unwrap_err().slug(),
183 "invalid-ci-spec"
184 );
185 }
186
187 #[test]
188 fn rejects_a_branch_that_is_not_a_fully_qualified_ref() {
189 assert!(Spec::parse("[run]\nsteps=[\"true\"]\nbranches=[\"../../config\"]").is_err());
190 assert!(Spec::parse("[run]\nsteps=[\"true\"]\nbranches=[\"main\"]").is_err());
191 }
192
193 #[test]
194 fn rejects_an_oversized_spec_or_step_list() {
195 let many = (0..MAX_STEPS + 1)
196 .map(|_| "\"true\"".to_string())
197 .collect::<Vec<_>>()
198 .join(",");
199 assert!(Spec::parse(&format!("[run]\nsteps = [{many}]")).is_err());
200 assert!(Spec::parse(&"x".repeat(MAX_SPEC_BYTES + 1)).is_err());
201 }
202
203 #[test]
204 fn a_repository_may_lower_its_timeout_but_never_raise_it() {
205 let ceiling = Duration::from_secs(300);
206
207 let lower = Spec::parse("[run]\nsteps=[\"true\"]\ntimeout_secs = 60").unwrap();
208 assert_eq!(lower.timeout(ceiling), Duration::from_secs(60));
209
210 let higher = Spec::parse("[run]\nsteps=[\"true\"]\ntimeout_secs = 99999").unwrap();
211 assert_eq!(
212 higher.timeout(ceiling),
213 ceiling,
214 "a repository must not hold a runner slot longer than the host allows"
215 );
216 }
217
218 #[test]
219 fn a_zero_timeout_is_refused_rather_than_meaning_forever() {
220 assert!(Spec::parse("[run]\nsteps=[\"true\"]\ntimeout_secs = 0").is_err());
221 }
222}