Menu
Public repository / 8d41f1e10aceed387d425b43038f2070e399a349
AkurAI-Build / src/config.rs
Latest change 8d41f1e10aceed387d425b43038f2070e399a349 - OLI-277: schedules, reusable workflows (include + inputs), per-repository secrets (value or PassVault), native caches by Ólafur Búi Ólafsson
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Component, Path};
use anyhow::{Context, Result, bail, ensure};
use serde::{Deserialize, Serialize};
const MAX_PIPELINE_BYTES: usize = 256 * 1024;
const MAX_JOBS: usize = 64;
const MAX_VARIANTS: usize = 32;
const MAX_EXPANDED_JOBS: usize = 256;
const MAX_COMMAND_BYTES: usize = 64 * 1024;
const MAX_INCLUDES: usize = 16;
const MAX_SCHEDULES: usize = 8;
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct PipelineInput {
version: u8,
#[serde(default)]
include: Vec<IncludeInput>,
#[serde(default)]
schedules: Vec<ScheduleInput>,
#[serde(default)]
jobs: Vec<JobInput>,
}
/// A reusable workflow (OLI-277): a `version: 1` file with `jobs`, taken from
/// this repository at the run's commit or from another hosted repository.
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct IncludeInput {
path: String,
repo: Option<String>,
#[serde(rename = "ref")]
reference: Option<String>,
#[serde(default)]
with: BTreeMap<String, String>,
}
/// An included file may only carry jobs.
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct IncludedInput {
version: u8,
jobs: Vec<JobInput>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct ScheduleInput {
cron: String,
branch: Option<String>,
}
/// A cron trigger on a branch (OLI-277); `None` means the default branch.
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct Schedule {
pub cron: String,
pub branch: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Pipeline {
pub jobs: Vec<JobSpec>,
pub schedules: Vec<Schedule>,
}
/// Resolves `include` entries: the runner reads the workspace and hosted
/// repositories; tests and linting pass [`NoIncludes`].
pub trait Loader {
fn load(&self, repo: Option<&str>, reference: Option<&str>, path: &str) -> Result<String>;
}
pub struct NoIncludes;
impl Loader for NoIncludes {
fn load(&self, repo: Option<&str>, _reference: Option<&str>, path: &str) -> Result<String> {
bail!(
"includes are not available here ({}{path})",
repo.map(|r| format!("{r}:")).unwrap_or_default()
)
}
}
/// Replace `${{ inputs.NAME }}` with the include's `with` values.
fn substitute_inputs(source: &str, inputs: &BTreeMap<String, String>) -> Result<String> {
for (name, value) in inputs {
validate_name("input", name)?;
ensure!(
value.len() <= 4096 && !value.contains('\n'),
"input {name} must be one line of at most 4096 bytes"
);
}
let mut out = String::with_capacity(source.len());
let mut rest = source;
while let Some(start) = rest.find("${{") {
out.push_str(&rest[..start]);
let after = &rest[start + 3..];
let end = after
.find("}}")
.context("unterminated ${{ in included workflow")?;
let expression = after[..end].trim();
let name = expression.strip_prefix("inputs.").with_context(|| {
format!("only ${{{{ inputs.NAME }}}} is supported, not ${{{{ {expression} }}}}")
})?;
let value = inputs.get(name).with_context(|| {
format!("included workflow needs input {name} (set it under `with`)")
})?;
out.push_str(value);
rest = &after[end + 2..];
}
out.push_str(rest);
Ok(out)
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct JobInput {
name: String,
needs: Option<Vec<String>>,
#[serde(default = "docker")]
executor: String,
image: Option<String>,
shell: Option<String>,
run: Commands,
#[serde(default)]
matrix: BTreeMap<String, Vec<String>>,
#[serde(default)]
artifacts: Vec<String>,
#[serde(default)]
cache: Vec<String>,
#[serde(default)]
network: bool,
#[serde(default)]
secrets: Vec<String>,
#[serde(default)]
installation_id: Option<String>,
#[serde(default)]
installation_revision: Option<i64>,
#[serde(default)]
rollback_of_deployment_id: Option<i64>,
#[serde(default)]
recovery_of_deployment_id: Option<i64>,
environment: Option<String>,
#[serde(default)]
approval: bool,
#[serde(default)]
branches: Vec<String>,
#[serde(default = "default_timeout")]
timeout: u64,
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum Commands {
One(String),
Many(Vec<String>),
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct JobSpec {
pub base_name: String,
pub name: String,
pub needs: Vec<String>,
pub executor: String,
pub image: Option<String>,
pub shell: Option<String>,
pub command: String,
pub matrix: BTreeMap<String, String>,
pub artifacts: Vec<String>,
pub cache: Vec<String>,
pub network: bool,
pub secrets: Vec<String>,
#[serde(default)]
pub installation_id: Option<String>,
#[serde(default)]
pub installation_revision: Option<i64>,
#[serde(default)]
pub rollback_of_deployment_id: Option<i64>,
#[serde(default)]
pub recovery_of_deployment_id: Option<i64>,
pub environment: Option<String>,
pub approval: bool,
pub branches: Vec<String>,
pub timeout: u64,
}
fn docker() -> String {
"docker".into()
}
const fn default_timeout() -> u64 {
1800
}
pub fn parse(source: &str) -> Result<Vec<JobSpec>> {
Ok(parse_pipeline(source, &NoIncludes)?.jobs)
}
/// Parse a pipeline with its includes and schedules (OLI-277).
pub fn parse_pipeline(source: &str, loader: &dyn Loader) -> Result<Pipeline> {
ensure!(source.len() <= MAX_PIPELINE_BYTES, "pipeline is too large");
let pipeline: PipelineInput = serde_yaml::from_str(source).context("parse .akurai.yml")?;
ensure!(pipeline.version == 1, "pipeline version must be 1");
ensure!(
pipeline.include.len() <= MAX_INCLUDES,
"pipeline includes more than {MAX_INCLUDES} files"
);
ensure!(
pipeline.schedules.len() <= MAX_SCHEDULES,
"pipeline has more than {MAX_SCHEDULES} schedules"
);
// Included files first, each its own implicit chain, then the file's own jobs.
let mut files: Vec<Vec<JobInput>> = Vec::new();
for include in &pipeline.include {
validate_relative_path(&include.path, false)?;
if let Some(repo) = &include.repo {
validate_repo_name(repo)?;
}
if let Some(reference) = &include.reference {
validate_ref(reference)?;
}
ensure!(
include.reference.is_none() || include.repo.is_some(),
"include ref needs repo (this repository is read at the run's commit)"
);
let text = loader.load(
include.repo.as_deref(),
include.reference.as_deref(),
&include.path,
)?;
ensure!(
text.len() <= MAX_PIPELINE_BYTES,
"included workflow {} is too large",
include.path
);
let text = substitute_inputs(&text, &include.with)?;
let included: IncludedInput = serde_yaml::from_str(&text)
.with_context(|| format!("parse included workflow {}", include.path))?;
ensure!(
included.version == 1,
"included workflow {} must be version 1",
include.path
);
ensure!(
!included.jobs.is_empty(),
"included workflow {} has no jobs",
include.path
);
files.push(included.jobs);
}
files.push(pipeline.jobs);
let total: usize = files.iter().map(Vec::len).sum();
ensure!(
(1..=MAX_JOBS).contains(&total),
"pipeline must contain 1..={MAX_JOBS} jobs"
);
let mut names = BTreeSet::new();
for job in files.iter().flatten() {
validate_name("job", &job.name)?;
ensure!(
names.insert(job.name.clone()),
"duplicate job: {}",
job.name
);
}
let mut schedules = Vec::new();
for schedule in pipeline.schedules {
crate::cron::Cron::parse(&schedule.cron)
.with_context(|| format!("schedule {:?}", schedule.cron))?;
if let Some(branch) = &schedule.branch {
validate_ref(branch)?;
}
let cron = schedule
.cron
.split_whitespace()
.collect::<Vec<_>>()
.join(" ");
ensure!(
!schedules
.iter()
.any(|s: &Schedule| s.cron == cron && s.branch == schedule.branch),
"duplicate schedule {cron}"
);
schedules.push(Schedule {
cron,
branch: schedule.branch,
});
}
let mut expanded = Vec::new();
let mut graph = BTreeMap::new();
for jobs in files {
let mut previous: Option<String> = None;
for job in jobs {
validate_job(&job)?;
let needs = job
.needs
.clone()
.unwrap_or_else(|| previous.iter().cloned().collect());
for need in &needs {
ensure!(
names.contains(need),
"job {} needs unknown job {need}",
job.name
);
ensure!(need != &job.name, "job {} cannot need itself", job.name);
}
graph.insert(job.name.clone(), needs.clone());
let variants = expand_matrix(&job.matrix)?;
for matrix in variants {
let suffix = matrix
.iter()
.map(|(key, value)| format!("{key}={value}"))
.collect::<Vec<_>>()
.join(",");
ensure!(
expanded.len() < MAX_EXPANDED_JOBS,
"pipeline expands beyond {MAX_EXPANDED_JOBS} jobs"
);
expanded.push(JobSpec {
base_name: job.name.clone(),
name: if suffix.is_empty() {
job.name.clone()
} else {
format!("{}[{suffix}]", job.name)
},
needs: needs.clone(),
executor: job.executor.clone(),
image: job.image.clone(),
shell: job.shell.clone(),
command: commands(&job.run)?,
matrix,
artifacts: job.artifacts.clone(),
cache: job.cache.clone(),
network: job.network,
installation_id: job.installation_id.clone(),
installation_revision: job.installation_revision,
rollback_of_deployment_id: job.rollback_of_deployment_id,
recovery_of_deployment_id: job.recovery_of_deployment_id,
environment: job.environment.clone(),
secrets: job.secrets.clone(),
approval: job.approval,
branches: job.branches.clone(),
timeout: job.timeout,
});
}
previous = Some(job.name);
}
}
validate_dag(&graph)?;
Ok(Pipeline {
jobs: expanded,
schedules,
})
}
fn validate_job(job: &JobInput) -> Result<()> {
ensure!(
matches!(job.executor.as_str(), "docker" | "native"),
"job {} executor must be docker or native",
job.name
);
if job.executor == "docker" {
let image = job.image.as_deref().context("docker job requires image")?;
ensure!(
!image.starts_with('-') && !image.chars().any(char::is_whitespace),
"invalid Docker image"
);
ensure!(image.len() <= 256, "Docker image is too long");
} else {
ensure!(job.image.is_none(), "native job cannot set image");
}
if job.installation_id.is_some() || job.installation_revision.is_some() {
ensure!(
job.environment.is_some(),
"managed installation provenance requires an environment"
);
ensure!(
job.installation_id.is_some() && job.installation_revision.is_some(),
"managed installation provenance requires installation_id and installation_revision"
);
ensure!(
job.installation_revision
.is_some_and(|revision| revision > 0),
"installation_revision must be positive"
);
ensure!(
job.installation_id
.as_ref()
.is_some_and(|id| !id.is_empty() && id.len() <= 128),
"installation_id must be 1..=128 bytes"
);
}
for (name, id) in [
("rollback_of_deployment_id", job.rollback_of_deployment_id),
("recovery_of_deployment_id", job.recovery_of_deployment_id),
] {
ensure!(id.is_none_or(|value| value > 0), "{name} must be positive");
}
ensure!(
(job.rollback_of_deployment_id.is_none() && job.recovery_of_deployment_id.is_none())
|| (job.installation_id.is_some() && job.installation_revision.is_some()),
"rollback/recovery lineage requires managed installation provenance"
);
ensure!(
!job.approval || job.environment.is_some(),
"approval requires an environment"
);
if let Some(environment) = &job.environment {
validate_name("environment", environment)?;
}
if let Some(shell) = &job.shell {
ensure!(
matches!(shell.as_str(), "sh" | "cmd" | "powershell"),
"unsupported shell {shell}"
);
}
if let Some(platforms) = job.matrix.get("platform") {
for platform in platforms {
validate_platform(platform)?;
}
}
ensure!(
(1..=14_400).contains(&job.timeout),
"timeout must be 1..=14400 seconds"
);
ensure!(job.artifacts.len() <= 64, "too many artifact patterns");
ensure!(job.cache.len() <= 16, "too many cache paths");
for path in &job.artifacts {
validate_relative_path(path, true)?;
}
for path in &job.cache {
validate_relative_path(path, false)?;
}
for secret in &job.secrets {
ensure!(valid_env_name(secret), "invalid secret name: {secret}");
}
for branch in &job.branches {
validate_ref(branch)?;
}
commands(&job.run)?;
Ok(())
}
fn commands(value: &Commands) -> Result<String> {
let values = match value {
Commands::One(value) => vec![value.as_str()],
Commands::Many(values) => values.iter().map(String::as_str).collect(),
};
ensure!(!values.is_empty(), "run must not be empty");
ensure!(
values.iter().all(|value| !value.trim().is_empty()),
"run command must not be empty"
);
let joined = values.join("\n");
ensure!(
joined.len() <= MAX_COMMAND_BYTES,
"run command is too large"
);
Ok(joined)
}
fn expand_matrix(matrix: &BTreeMap<String, Vec<String>>) -> Result<Vec<BTreeMap<String, String>>> {
let mut variants = vec![BTreeMap::new()];
for (key, values) in matrix {
validate_name("matrix key", key)?;
ensure!(!values.is_empty(), "matrix axis {key} is empty");
ensure!(
values.len() <= MAX_VARIANTS,
"matrix axis {key} is too large"
);
let mut next = Vec::new();
for variant in &variants {
for value in values {
ensure!(
!value.is_empty() && value.len() <= 128,
"invalid matrix value"
);
let mut item = variant.clone();
item.insert(key.clone(), value.clone());
next.push(item);
ensure!(
next.len() <= MAX_VARIANTS,
"matrix expands beyond {MAX_VARIANTS} jobs"
);
}
}
variants = next;
}
Ok(variants)
}
fn validate_dag(graph: &BTreeMap<String, Vec<String>>) -> Result<()> {
let mut done = BTreeSet::new();
while done.len() < graph.len() {
let before = done.len();
for (name, needs) in graph {
if needs.iter().all(|need| done.contains(need)) {
done.insert(name.clone());
}
}
if done.len() == before {
bail!("pipeline contains a dependency cycle");
}
}
Ok(())
}
pub fn validate_ref(value: &str) -> Result<()> {
ensure!(!value.is_empty() && value.len() <= 200, "invalid Git ref");
ensure!(
!value.starts_with('-') && !value.starts_with('/') && !value.ends_with('/'),
"invalid Git ref"
);
ensure!(
!value.contains("..") && !value.contains("@{") && !value.contains('\\'),
"invalid Git ref"
);
ensure!(
value
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || b"._/-".contains(&byte)),
"invalid Git ref"
);
Ok(())
}
pub fn validate_repo_name(name: &str) -> Result<()> {
ensure!(
!name.is_empty()
&& name.len() <= 64
&& name
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || b"._-".contains(&byte)),
"invalid repository name"
);
Ok(())
}
pub fn validate_repository(name: &str, url: &str, branch: &str) -> Result<()> {
validate_repo_name(name)?;
ensure!(
!url.starts_with('-') && url.len() <= 2048,
"invalid repository URL"
);
ensure!(
Path::new(url).is_absolute()
|| url.starts_with("https://")
|| url.starts_with("ssh://")
|| url.starts_with("git@"),
"repository must use HTTPS, SSH, or an absolute local path"
);
validate_ref(branch)
}
pub fn validate_commit(value: &str) -> Result<()> {
ensure!(
value.len() == 40 || value.len() == 64,
"commit must be a full hexadecimal object id"
);
ensure!(
value.bytes().all(|byte| byte.is_ascii_hexdigit()),
"invalid commit"
);
Ok(())
}
fn validate_relative_path(value: &str, allow_glob: bool) -> Result<()> {
ensure!(
!value.is_empty() && value.len() <= 512,
"invalid relative path"
);
ensure!(
!value.contains(['\\', ':', '\0']) && (allow_glob || !value.contains(['*', '?', '[', ']'])),
"invalid path syntax"
);
let plain = value.replace(['*', '?', '[', ']'], "x");
ensure!(
Path::new(&plain)
.components()
.all(|component| matches!(component, Component::Normal(_))),
"path must stay inside the workspace"
);
Ok(())
}
fn validate_platform(value: &str) -> Result<()> {
let mut parts = value.split('/');
let os = parts.next().unwrap_or_default();
let architecture = parts.next().unwrap_or_default();
ensure!(
os == "linux"
&& !architecture.is_empty()
&& parts.next().is_none()
&& [os, architecture].into_iter().all(|part| {
part.bytes().all(|byte| {
byte.is_ascii_lowercase()
|| byte.is_ascii_digit()
|| matches!(byte, b'_' | b'-')
})
}),
"invalid Docker platform: {value}"
);
Ok(())
}
fn validate_name(label: &str, value: &str) -> Result<()> {
ensure!(!value.is_empty() && value.len() <= 64, "invalid {label}");
ensure!(
value
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || b"._-".contains(&byte)),
"invalid {label}: {value}"
);
Ok(())
}
fn valid_env_name(value: &str) -> bool {
let mut bytes = value.bytes();
matches!(bytes.next(), Some(b'A'..=b'Z') | Some(b'_'))
&& bytes.all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit() || byte == b'_')
}
#[cfg(test)]
mod tests {
use super::*;
// ── parse ────────────────────────────────────────────────────────
const MINIMAL: &str =
"version: 1\njobs:\n - name: build\n image: alpine:3\n run: echo ok\n";
struct MapLoader(BTreeMap<String, String>);
impl Loader for MapLoader {
fn load(&self, repo: Option<&str>, reference: Option<&str>, path: &str) -> Result<String> {
let key = match (repo, reference) {
(Some(repo), Some(reference)) => format!("{repo}@{reference}:{path}"),
(Some(repo), None) => format!("{repo}:{path}"),
(None, _) => path.to_owned(),
};
self.0
.get(&key)
.cloned()
.with_context(|| format!("no such file {key}"))
}
}
const RUST_WORKFLOW: &str = "version: 1\njobs:\n - name: fmt\n executor: native\n run: cargo +${{ inputs.toolchain }} fmt --check\n - name: test\n executor: native\n run: cargo +${{ inputs.toolchain }} test\n";
#[test]
fn includes_reusable_workflows_with_inputs_and_their_own_chains() -> Result<()> {
let mut files = BTreeMap::new();
files.insert(".akurai/rust.yml".to_string(), RUST_WORKFLOW.to_string());
files.insert("AkurAI-Build@main:ci/deploy.yml".to_string(), "version: 1\njobs:\n - name: deploy\n executor: native\n needs: [test]\n run: echo deploy\n".to_string());
let loader = MapLoader(files);
let source = "version: 1\ninclude:\n - path: .akurai/rust.yml\n with: {toolchain: stable}\n - repo: AkurAI-Build\n ref: main\n path: ci/deploy.yml\njobs:\n - name: own\n image: alpine:3\n run: echo own\n";
let pipeline = parse_pipeline(source, &loader)?;
let names: Vec<&str> = pipeline.jobs.iter().map(|j| j.name.as_str()).collect();
assert_eq!(names, ["fmt", "test", "deploy", "own"]);
assert_eq!(pipeline.jobs[1].command, "cargo +stable test");
assert_eq!(
pipeline.jobs[1].needs,
vec!["fmt".to_string()],
"chain inside the include"
);
assert_eq!(
pipeline.jobs[2].needs,
vec!["test".to_string()],
"explicit cross-file need"
);
assert!(
pipeline.jobs[3].needs.is_empty(),
"the file's own first job does not chain to an include"
);
// missing input, foreign expression, unknown file, nested include, bad ref
let missing = "version: 1\ninclude:\n - path: .akurai/rust.yml\njobs: []\n";
let error = parse_pipeline(missing, &loader).expect_err("missing input");
assert!(
error.to_string().contains("needs input toolchain"),
"{error:#}"
);
assert!(parse_pipeline("version: 1\ninclude:\n - path: nope.yml\n", &loader).is_err());
assert!(parse_pipeline("version: 1\ninclude:\n - path: ../x.yml\n", &loader).is_err());
assert!(
parse_pipeline(
"version: 1\ninclude:\n - path: a.yml\n ref: main\n",
&loader
)
.is_err(),
"ref without repo"
);
assert!(parse(source).is_err(), "plain parse has no loader");
Ok(())
}
#[test]
fn schedules_are_validated_and_normalised() -> Result<()> {
let source = "version: 1\nschedules:\n - cron: '0 3 * * *'\n - cron: '*/15 * * * *'\n branch: nightly\njobs:\n - name: build\n image: alpine:3\n run: echo ok\n";
let pipeline = parse_pipeline(source, &NoIncludes)?;
assert_eq!(
pipeline.schedules,
vec![
Schedule {
cron: "0 3 * * *".into(),
branch: None
},
Schedule {
cron: "*/15 * * * *".into(),
branch: Some("nightly".into())
}
]
);
assert!(parse_pipeline("version: 1\nschedules:\n - cron: 'x'\njobs:\n - name: b\n image: a\n run: echo\n", &NoIncludes).is_err());
assert!(parse_pipeline("version: 1\nschedules:\n - cron: '0 3 * * *'\n - cron: '0 3 * * *'\njobs:\n - name: b\n image: a\n run: echo\n", &NoIncludes).is_err(), "duplicate");
assert!(
parse_pipeline(
"version: 1\nschedules:\n - cron: '0 3 * * *'\n",
&NoIncludes
)
.is_err(),
"no jobs"
);
Ok(())
}
#[test]
fn parses_minimal_valid_pipeline() -> Result<()> {
let jobs = parse(MINIMAL)?;
assert_eq!(jobs.len(), 1);
let job = &jobs[0];
assert_eq!(job.name, "build");
assert_eq!(job.executor, "docker");
assert_eq!(job.image.as_deref(), Some("alpine:3"));
assert_eq!(job.command, "echo ok");
assert!(job.cache.is_empty());
assert!(job.artifacts.is_empty());
Ok(())
}
#[test]
fn parses_native_executor() -> Result<()> {
let jobs = parse(
"version: 1\njobs:\n - name: lint\n executor: native\n run: cargo fmt --check\n",
)?;
assert_eq!(jobs[0].executor, "native");
assert!(jobs[0].image.is_none());
Ok(())
}
#[test]
fn expands_matrix_and_defaults_to_linear_dependencies() -> Result<()> {
let jobs = parse(
"version: 1\njobs:\n - name: test\n image: rust:1\n run: cargo test\n matrix:\n platform: [linux/amd64, linux/arm64]\n - name: package\n image: rust:1\n run: cargo build\n",
)?;
assert_eq!(jobs.len(), 3);
assert_eq!(jobs[2].needs, ["test"]);
Ok(())
}
#[test]
fn repository_pipeline_is_valid() -> Result<()> {
let jobs = parse(include_str!("../.akurai.yml"))?;
assert_eq!(jobs.len(), 4);
assert_eq!(jobs[0].name, "lint-config");
let deploy = &jobs[3];
assert_eq!(deploy.name, "deploy-production");
assert_eq!(deploy.needs, ["package"]);
assert_eq!(deploy.environment.as_deref(), Some("production"));
assert!(deploy.approval);
Ok(())
}
#[test]
fn parse_rejects_missing_version() {
assert!(parse("jobs:\n - name: build\n image: alpine:3\n run: echo ok\n").is_err());
}
#[test]
fn parse_rejects_wrong_version() {
assert!(
parse("version: 2\njobs:\n - name: build\n image: alpine:3\n run: echo ok\n")
.is_err()
);
}
#[test]
fn parse_rejects_empty_jobs() {
assert!(parse("version: 1\njobs: []\n").is_err());
assert!(parse("version: 1\n").is_err());
}
#[test]
fn parse_rejects_too_many_jobs() {
let mut yaml = String::from("version: 1\njobs:\n");
for i in 0..65 {
yaml.push_str(&format!(
" - name: job{i}\n image: alpine:3\n run: echo ok\n"
));
}
assert!(parse(&yaml).is_err());
}
#[test]
fn parse_rejects_missing_job_name() {
assert!(parse("version: 1\njobs:\n - image: alpine:3\n run: echo ok\n").is_err());
}
#[test]
fn parse_rejects_missing_run() {
assert!(parse("version: 1\njobs:\n - name: build\n image: alpine:3\n").is_err());
}
#[test]
fn parse_rejects_empty_run() {
assert!(
parse("version: 1\njobs:\n - name: build\n image: alpine:3\n run: \"\"\n")
.is_err()
);
assert!(
parse(
"version: 1\njobs:\n - name: build\n image: alpine:3\n run:\n - \"\"\n"
)
.is_err()
);
}
#[test]
fn parse_rejects_duplicate_job_names() {
let source = "version: 1\njobs:\n - name: build\n image: alpine:3\n run: echo a\n - name: build\n image: alpine:3\n run: echo b\n";
assert!(parse(source).is_err());
}
#[test]
fn parse_rejects_self_dependency() {
let source = "version: 1\njobs:\n - name: build\n image: alpine:3\n run: echo a\n needs: [build]\n";
assert!(parse(source).is_err());
}
#[test]
fn parse_rejects_unknown_dependency() {
let source = "version: 1\njobs:\n - name: build\n image: alpine:3\n run: echo a\n needs: [nonexistent]\n";
assert!(parse(source).is_err());
}
#[test]
fn parse_rejects_invalid_executor() {
let source = "version: 1\njobs:\n - name: build\n executor: podman\n image: alpine:3\n run: echo ok\n";
assert!(parse(source).is_err());
}
#[test]
fn parse_rejects_native_with_image() {
let source = "version: 1\njobs:\n - name: build\n executor: native\n image: alpine:3\n run: echo ok\n";
assert!(parse(source).is_err());
}
#[test]
fn parse_rejects_docker_without_image() {
let source = "version: 1\njobs:\n - name: build\n executor: docker\n run: echo ok\n";
assert!(parse(source).is_err());
}
#[test]
fn parse_rejects_approval_without_environment() {
let source = "version: 1\njobs:\n - name: build\n image: alpine:3\n run: echo ok\n approval: true\n";
assert!(parse(source).is_err());
}
#[test]
fn parse_accepts_approval_with_environment() -> Result<()> {
let source = "version: 1\njobs:\n - name: deploy\n image: alpine:3\n run: echo ok\n approval: true\n environment: production\n";
let jobs = parse(source)?;
assert!(jobs[0].approval);
assert_eq!(jobs[0].environment.as_deref(), Some("production"));
Ok(())
}
#[test]
fn parse_rejects_cycles_traversal_and_unknown_fields() {
for source in [
"version: 1\njobs:\n- name: a\n needs: [b]\n image: x\n run: x\n- name: b\n needs: [a]\n image: x\n run: x\n",
"version: 1\njobs:\n- name: a\n image: x\n run: x\n artifacts: [../secret]\n",
"version: 1\njobs:\n- name: a\n image: x\n run: x\n magic: true\n",
"version: 1\njobs:\n- name: a\n image: x\n run: x\n approval: true\n",
"version: 1\njobs:\n- name: a\n image: x\n run: x\n cache: ['cache/**']\n",
"version: 1\njobs:\n- name: a\n image: x\n run: x\n matrix:\n platform: ['--privileged']\n",
] {
assert!(parse(source).is_err(), "expected error for: {source}");
}
}
// ── validate_repo_name ───────────────────────────────────────────
#[test]
fn repo_name_accepts_valid() -> Result<()> {
for name in [
"app",
"my-app",
"app_v2",
"my.repo",
"a",
"123",
"_underscore",
] {
validate_repo_name(name).with_context(|| format!("should accept {name:?}"))?;
}
Ok(())
}
#[test]
fn repo_name_rejects_empty() {
assert!(validate_repo_name("").is_err());
}
#[test]
fn repo_name_rejects_path_traversal() {
for name in ["../escape", "./hidden", "sub/dir", "/etc", "a\\b"] {
assert!(validate_repo_name(name).is_err(), "should reject {name:?}");
}
}
#[test]
fn repo_name_rejects_control_chars() {
for name in ["a\nb", "tab\there", "null\0byte"] {
assert!(
validate_repo_name(name).is_err(),
"should reject control chars in {name:?}"
);
}
}
#[test]
fn repo_name_rejects_over_length() {
let long = "a".repeat(65);
assert!(validate_repo_name(&long).is_err());
}
#[test]
fn repo_name_rejects_shell_injection() {
for name in ["$(whoami)", "`id`", "a;rm -rf /", "a|cat /etc/passwd"] {
assert!(validate_repo_name(name).is_err(), "should reject {name:?}");
}
}
// ── validate_ref ─────────────────────────────────────────────────
#[test]
fn ref_accepts_valid() -> Result<()> {
for value in [
"main",
"refs/heads/main",
"feature/branch",
"v1.0.0",
"fix-123",
"a",
"release/2024.01",
] {
validate_ref(value).with_context(|| format!("should accept {value:?}"))?;
}
Ok(())
}
#[test]
fn ref_rejects_empty() {
assert!(validate_ref("").is_err());
}
#[test]
fn ref_rejects_leading_slash() {
assert!(validate_ref("/etc").is_err());
}
#[test]
fn ref_rejects_leading_dash() {
assert!(validate_ref("-f").is_err());
}
#[test]
fn ref_rejects_trailing_slash() {
assert!(validate_ref("branch/").is_err());
}
#[test]
fn ref_rejects_dotdot() {
assert!(validate_ref("a..b").is_err());
assert!(validate_ref("../escape").is_err());
}
#[test]
fn ref_rejects_at_brace() {
assert!(validate_ref("ref@{upstream}").is_err());
}
#[test]
fn ref_rejects_backslash() {
assert!(validate_ref("a\\b").is_err());
}
#[test]
fn ref_rejects_control_chars() {
for value in ["a\nb", "tab\there"] {
assert!(validate_ref(value).is_err(), "should reject {value:?}");
}
}
#[test]
fn ref_rejects_over_length() {
let long = "a".repeat(201);
assert!(validate_ref(&long).is_err());
}
#[test]
fn ref_rejects_shell_injection() {
for value in ["$(whoami)", "`id`", "a;rm -rf /"] {
assert!(validate_ref(value).is_err(), "should reject {value:?}");
}
}
}