1use std::collections::BTreeSet;
5
6use prost::Message;
7use thiserror::Error;
8
9use crate::heddle::api::v1alpha1::{
10 TreadleCheck, TreadleCheckClass, TreadleDefinition, TreadleDeterminismClass, TreadleEnvEntry,
11 TreadleJob, TreadleNetworkAccess, TreadleSecretTier, TreadleServiceContainer, TreadleTrigger,
12 TreadleTriggerKind, treadle_env_entry,
13};
14
15pub const TREADLE_DEFINITION_FORMAT_VERSION: u32 = 1;
17
18#[derive(Debug, Error)]
20pub enum TreadleDefinitionError {
21 #[error(
22 "unsupported treadle definition format version {actual}; migrate to version {expected} before reading"
23 )]
24 UnsupportedVersion { actual: u32, expected: u32 },
25 #[error("invalid treadle definition: {0}")]
26 Invalid(String),
27 #[error("invalid treadle protobuf: {0}")]
28 Decode(#[from] prost::DecodeError),
29 #[error("treadle definition bytes are not canonical")]
30 NonCanonicalBytes,
31}
32
33pub fn canonical_treadle_definition_bytes(
35 definition: &TreadleDefinition,
36) -> Result<Vec<u8>, TreadleDefinitionError> {
37 let normalized = canonical_treadle_definition(definition)?;
38 Ok(normalized.encode_to_vec())
39}
40
41pub fn treadle_definition_blake3(
43 definition: &TreadleDefinition,
44) -> Result<[u8; 32], TreadleDefinitionError> {
45 Ok(*blake3::hash(&canonical_treadle_definition_bytes(definition)?).as_bytes())
46}
47
48pub fn decode_canonical_treadle_definition(
54 bytes: &[u8],
55) -> Result<TreadleDefinition, TreadleDefinitionError> {
56 let decoded = TreadleDefinition::decode(bytes)?;
57 let normalized = canonical_treadle_definition(&decoded)?;
58 if normalized.encode_to_vec() != bytes {
59 return Err(TreadleDefinitionError::NonCanonicalBytes);
60 }
61 Ok(normalized)
62}
63
64fn canonical_treadle_definition(
65 definition: &TreadleDefinition,
66) -> Result<TreadleDefinition, TreadleDefinitionError> {
67 if definition.format_version != TREADLE_DEFINITION_FORMAT_VERSION {
68 return Err(TreadleDefinitionError::UnsupportedVersion {
69 actual: definition.format_version,
70 expected: TREADLE_DEFINITION_FORMAT_VERSION,
71 });
72 }
73 non_empty("pipeline name", &definition.name)?;
74
75 let mut normalized = definition.clone();
76 normalized
77 .secret_refs
78 .sort_by(|left, right| left.name.cmp(&right.name));
79 normalized
80 .services
81 .sort_by(|left, right| left.name.cmp(&right.name));
82 normalized
83 .jobs
84 .sort_by(|left, right| left.name.cmp(&right.name));
85 if normalized.jobs.is_empty() {
86 return invalid("pipeline has no jobs".into());
87 }
88
89 let secret_names = unique_named(
90 "secret declaration",
91 normalized
92 .secret_refs
93 .iter()
94 .map(|secret| secret.name.as_str()),
95 )?;
96 for secret in &normalized.secret_refs {
97 identifier("secret", &secret.name)?;
98 no_nul("secret provider", &secret.provider)?;
99 match TreadleSecretTier::try_from(secret.tier) {
100 Ok(TreadleSecretTier::Standard | TreadleSecretTier::TrustedRunnerOnly) => {}
101 _ => return invalid(format!("secret {:?} has an invalid tier", secret.name)),
102 }
103 }
104
105 let service_names = unique_named(
106 "service",
107 normalized
108 .services
109 .iter()
110 .map(|service| service.name.as_str()),
111 )?;
112 for service in &mut normalized.services {
113 normalize_service(service, &secret_names)?;
114 }
115
116 unique_named("job", normalized.jobs.iter().map(|job| job.name.as_str()))?;
117 for job in &mut normalized.jobs {
118 normalize_job(job, &service_names, &secret_names)?;
119 }
120
121 Ok(normalized)
122}
123
124fn normalize_job(
125 job: &mut TreadleJob,
126 service_names: &BTreeSet<String>,
127 secret_names: &BTreeSet<String>,
128) -> Result<(), TreadleDefinitionError> {
129 identifier("job", &job.name)?;
130 job.matrix.sort_by(|left, right| left.name.cmp(&right.name));
131 unique_named(
132 "matrix dimension",
133 job.matrix.iter().map(|value| value.name.as_str()),
134 )?;
135 for value in &job.matrix {
136 identifier("matrix dimension", &value.name)?;
137 no_nul("matrix value", &value.value)?;
138 if value.value.contains("${") || value.value.contains("{{") {
139 return invalid(format!(
140 "job {:?} matrix value {:?} looks unresolved",
141 job.name, value.value
142 ));
143 }
144 }
145
146 job.checks.sort_by(|left, right| left.name.cmp(&right.name));
147 unique_named("check", job.checks.iter().map(|check| check.name.as_str()))?;
148 if job.checks.is_empty() {
149 return invalid(format!("job {:?} has no checks", job.name));
150 }
151 for check in &mut job.checks {
152 normalize_check(check, service_names, secret_names)?;
153 }
154 Ok(())
155}
156
157fn normalize_check(
158 check: &mut TreadleCheck,
159 service_names: &BTreeSet<String>,
160 secret_names: &BTreeSet<String>,
161) -> Result<(), TreadleDefinitionError> {
162 identifier("check", &check.name)?;
163 non_empty("check command", &check.command)?;
164 for arg in &check.args {
165 no_nul("check argument", arg)?;
166 }
167 match TreadleCheckClass::try_from(check.class) {
168 Ok(
169 TreadleCheckClass::Required
170 | TreadleCheckClass::Advisory
171 | TreadleCheckClass::Informational,
172 ) => {}
173 _ => return invalid(format!("check {:?} has an invalid class", check.name)),
174 }
175 match TreadleDeterminismClass::try_from(check.determinism_class) {
176 Ok(TreadleDeterminismClass::Deterministic | TreadleDeterminismClass::Nondeterministic) => {}
177 _ => {
178 return invalid(format!(
179 "check {:?} has an invalid determinism_class",
180 check.name
181 ));
182 }
183 }
184 if check.timeout_seconds == 0 {
185 return invalid(format!(
186 "check {:?} timeout_seconds must be positive",
187 check.name
188 ));
189 }
190 let target_environment = check.target_environment.as_ref().ok_or_else(|| {
191 TreadleDefinitionError::Invalid(format!("check {:?} omits target_environment", check.name))
192 })?;
193 oci_image_digest(
194 "target environment OCI image digest",
195 &target_environment.oci_image_digest,
196 )?;
197 let platform = target_environment.platform.as_ref().ok_or_else(|| {
198 TreadleDefinitionError::Invalid(format!(
199 "check {:?} omits target_environment.platform",
200 check.name
201 ))
202 })?;
203 platform_value("target platform os", &platform.os)?;
204 platform_value("target platform arch", &platform.arch)?;
205 normalize_env(&mut check.env, secret_names)?;
206 relative_path("working_directory", &check.working_directory, true)?;
207
208 check.service_dependencies.sort();
209 unique_strings("service dependency", &check.service_dependencies)?;
210 for dependency in &check.service_dependencies {
211 if !service_names.contains(dependency) {
212 return invalid(format!(
213 "check {:?} references undeclared service {:?}",
214 check.name, dependency
215 ));
216 }
217 }
218
219 let retry = check.retry.as_mut().ok_or_else(|| {
220 TreadleDefinitionError::Invalid(format!("check {:?} omits retry", check.name))
221 })?;
222 retry.flake_signatures.sort();
223 unique_strings("flake signature", &retry.flake_signatures)?;
224 for signature in &retry.flake_signatures {
225 non_empty("flake signature", signature)?;
226 }
227
228 check.cache_paths.sort();
229 unique_strings("cache path", &check.cache_paths)?;
230 for path in &check.cache_paths {
231 relative_path("cache path", path, false)?;
232 }
233
234 let isolation = check.isolation.as_ref().ok_or_else(|| {
235 TreadleDefinitionError::Invalid(format!("check {:?} omits isolation", check.name))
236 })?;
237 no_nul("isolation profile", &isolation.profile)?;
238 if TreadleNetworkAccess::try_from(isolation.network_access).is_err() {
239 return invalid(format!("check {:?} has invalid network_access", check.name));
240 }
241
242 check.triggers.sort_by(|left, right| {
243 (left.kind, left.cron_expression.as_str())
244 .cmp(&(right.kind, right.cron_expression.as_str()))
245 });
246 if check.triggers.is_empty() {
247 return invalid(format!("check {:?} has no triggers", check.name));
248 }
249 let mut trigger_keys = BTreeSet::new();
250 for trigger in &check.triggers {
251 validate_trigger(check, trigger)?;
252 if !trigger_keys.insert((trigger.kind, trigger.cron_expression.clone())) {
253 return invalid(format!("check {:?} has a duplicate trigger", check.name));
254 }
255 }
256 Ok(())
257}
258
259fn validate_trigger(
260 check: &TreadleCheck,
261 trigger: &TreadleTrigger,
262) -> Result<(), TreadleDefinitionError> {
263 match TreadleTriggerKind::try_from(trigger.kind) {
264 Ok(TreadleTriggerKind::Push | TreadleTriggerKind::Manual)
265 if trigger.cron_expression.is_empty() =>
266 {
267 Ok(())
268 }
269 Ok(TreadleTriggerKind::Cron) if valid_cron(&trigger.cron_expression) => Ok(()),
270 _ => invalid(format!("check {:?} has an invalid trigger", check.name)),
271 }
272}
273
274fn normalize_service(
275 service: &mut TreadleServiceContainer,
276 secret_names: &BTreeSet<String>,
277) -> Result<(), TreadleDefinitionError> {
278 identifier("service", &service.name)?;
279 non_empty("service image", &service.image)?;
280 oci_image_digest("service OCI image digest", &service.oci_image_digest)?;
281 service.ports.sort_unstable();
282 if service
283 .ports
284 .iter()
285 .any(|port| *port == 0 || *port > u16::MAX.into())
286 {
287 return invalid(format!("service {:?} has an invalid port", service.name));
288 }
289 if service.ports.windows(2).any(|pair| pair[0] == pair[1]) {
290 return invalid(format!("service {:?} has a duplicate port", service.name));
291 }
292 normalize_env(&mut service.env, secret_names)?;
293 if let Some(readiness) = &service.readiness {
294 non_empty("service readiness command", &readiness.command)?;
295 for arg in &readiness.args {
296 no_nul("service readiness argument", arg)?;
297 }
298 }
299 Ok(())
300}
301
302fn normalize_env(
303 env: &mut [TreadleEnvEntry],
304 secret_names: &BTreeSet<String>,
305) -> Result<(), TreadleDefinitionError> {
306 env.sort_by(|left, right| left.name.cmp(&right.name));
307 unique_named(
308 "environment entry",
309 env.iter().map(|entry| entry.name.as_str()),
310 )?;
311 for entry in env {
312 non_empty("environment name", &entry.name)?;
313 match entry.source.as_ref() {
314 Some(treadle_env_entry::Source::LiteralValue(value)) => {
315 no_nul("environment literal", value)?;
316 }
317 Some(treadle_env_entry::Source::SecretRef(name)) if secret_names.contains(name) => {}
318 Some(treadle_env_entry::Source::SecretRef(name)) => {
319 return invalid(format!("environment references undeclared secret {name:?}"));
320 }
321 None => return invalid(format!("environment {:?} has no source", entry.name)),
322 }
323 }
324 Ok(())
325}
326
327fn unique_named<'a>(
328 kind: &str,
329 names: impl IntoIterator<Item = &'a str>,
330) -> Result<BTreeSet<String>, TreadleDefinitionError> {
331 let mut seen = BTreeSet::new();
332 for name in names {
333 if !seen.insert(name.to_owned()) {
334 return invalid(format!("duplicate {kind} name {name:?}"));
335 }
336 }
337 Ok(seen)
338}
339
340fn unique_strings(kind: &str, values: &[String]) -> Result<(), TreadleDefinitionError> {
341 if values.windows(2).any(|pair| pair[0] == pair[1]) {
342 return invalid(format!("duplicate {kind}"));
343 }
344 Ok(())
345}
346
347fn identifier(kind: &str, value: &str) -> Result<(), TreadleDefinitionError> {
348 if value.is_empty()
349 || !value.bytes().all(|byte| {
350 byte.is_ascii_lowercase() || byte.is_ascii_digit() || b"._:-".contains(&byte)
351 })
352 {
353 return invalid(format!("{kind} name {value:?} must match [a-z0-9._:-]+"));
354 }
355 Ok(())
356}
357
358fn non_empty(kind: &str, value: &str) -> Result<(), TreadleDefinitionError> {
359 if value.is_empty() {
360 return invalid(format!("{kind} must not be empty"));
361 }
362 no_nul(kind, value)
363}
364
365fn no_nul(kind: &str, value: &str) -> Result<(), TreadleDefinitionError> {
366 if value.contains('\0') {
367 return invalid(format!("{kind} must not contain NUL"));
368 }
369 Ok(())
370}
371
372fn oci_image_digest(kind: &str, value: &str) -> Result<(), TreadleDefinitionError> {
373 let Some(hex) = value.strip_prefix("sha256:") else {
374 return invalid(format!(
375 "{kind} must be sha256: followed by 64 lowercase hexadecimal digits"
376 ));
377 };
378 if hex.len() != 64
379 || !hex
380 .bytes()
381 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
382 {
383 return invalid(format!(
384 "{kind} must be sha256: followed by 64 lowercase hexadecimal digits"
385 ));
386 }
387 Ok(())
388}
389
390fn platform_value(kind: &str, value: &str) -> Result<(), TreadleDefinitionError> {
391 if value.is_empty()
392 || !value
393 .bytes()
394 .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit())
395 {
396 return invalid(format!("{kind} must match [a-z0-9]+"));
397 }
398 Ok(())
399}
400
401fn relative_path(kind: &str, value: &str, allow_empty: bool) -> Result<(), TreadleDefinitionError> {
402 if value.is_empty() {
403 return if allow_empty {
404 Ok(())
405 } else {
406 invalid(format!("{kind} must not be empty"))
407 };
408 }
409 if value.starts_with('/')
410 || value.contains('\\')
411 || value.as_bytes().get(1) == Some(&b':')
412 || value
413 .split('/')
414 .any(|segment| segment.is_empty() || segment == "." || segment == "..")
415 {
416 return invalid(format!(
417 "{kind} {value:?} must be a normalized repository-relative path"
418 ));
419 }
420 no_nul(kind, value)
421}
422
423fn valid_cron(value: &str) -> bool {
424 let fields = value.split(' ').collect::<Vec<_>>();
425 fields.len() == 5
426 && fields.iter().all(|field| {
427 !field.is_empty()
428 && field
429 .bytes()
430 .all(|byte| byte.is_ascii_digit() || b"*,-/".contains(&byte))
431 })
432}
433
434fn invalid<T>(message: String) -> Result<T, TreadleDefinitionError> {
435 Err(TreadleDefinitionError::Invalid(message))
436}