Skip to main content

heddle_api/
treadle.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Canonical encoding for the signed treadle definition contract.
3
4use 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
15/// The only treadle definition format accepted by this release.
16pub const TREADLE_DEFINITION_FORMAT_VERSION: u32 = 1;
17
18/// Canonical treadle definition validation/decoding errors.
19#[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
33/// Validate, normalize, and encode a definition using the v1 canonical wire rule.
34pub 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
41/// Return the BLAKE3 content address of the v1 canonical protobuf bytes.
42pub 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
48/// Decode only a byte-exact canonical current-version definition.
49///
50/// This rejects alternate protobuf encodings, unknown fields, unsorted repeated
51/// sets, invalid definitions, and old/future versions. Migration is a separate,
52/// explicit operation; this reader never provides a dual-read fallback.
53pub 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}