AkurAI Build
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:?}");
        }
    }
}