Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/edda

crates/dwarves-runa/src/validate.rs

//! Layer-1 **validator** — the load-time gate that makes a `*.runa.ron` error at
//! load, not at run.
//!
//! Two structural checks over the stage DAG:
//! 1. every `needs` entry names a real stage (no dangling dependency);
//! 2. the stage graph is acyclic.
//!
//! REUSE (Law #1): the cycle check does **not** re-implement Tarjan/Kahn. The
//! stages are projected onto a [`funnel_core::PlanDag`] (a neutral string-id DAG)
//! and run through its [`PlanDag::topo_order`](funnel_core::PlanDag::topo_order),
//! which is petgraph's `toposort` under the hood — the same topo engine the
//! nornir funnel uses. A duplicate stage-name check runs first, because two
//! stages sharing a name would make `needs` ambiguous.

use std::collections::BTreeSet;

use funnel_core::{DagStatus, EdgeKind, PlanDag, PlanDagEdge, PlanDagNode};

use crate::pipeline::Pipeline;
use crate::ValidationError;

/// Validate a parsed [`Pipeline`]'s stage DAG. Returns `Ok(())` for a well-formed
/// pipeline, else the first structural problem found.
pub(crate) fn validate(pipeline: &Pipeline) -> Result<(), ValidationError> {
    // 1. Stage names must be unique — a duplicate makes `needs` ambiguous and
    //    would silently collapse two nodes in the DAG projection.
    let mut names: BTreeSet<&str> = BTreeSet::new();
    for stage in &pipeline.stages {
        if !names.insert(stage.name.as_str()) {
            return Err(ValidationError::DuplicateStage {
                name: stage.name.clone(),
            });
        }
    }

    // 1b. An `Xtask` alias is a NAME, refused at load otherwise (never a path or a
    //     second argv slot).
    for stage in &pipeline.stages {
        for step in &stage.steps {
            if let crate::pipeline::Step::Xtask { alias, .. } = step {
                if !crate::pipeline::alias_is_a_name(alias) {
                    return Err(ValidationError::BadAlias { stage: stage.name.clone(), alias: alias.clone() });
                }
            }
            // a Bench names its package and its sub-directory, never paths
            if let crate::pipeline::Step::Bench { package, dir, .. } = step {
                let bad = |what: &str, name: &str| ValidationError::BadName { stage: stage.name.clone(), what: what.to_string(), name: name.to_string() };
                if !package.is_empty() && !crate::pipeline::is_a_name(package) {
                    return Err(bad("package", package));
                }
                if !dir.is_empty() && !crate::pipeline::is_a_name(dir) {
                    return Err(bad("dir", dir));
                }
            }
            // a Trace / Profile / LiveTrace names its package and target, never paths
            if let crate::pipeline::Step::Trace { target, name, package, .. }
            | crate::pipeline::Step::Profile { target, name, package, .. }
            | crate::pipeline::Step::LiveTrace { target, name, package, .. } = step
            {
                let bad = |what: &str, n: &str| ValidationError::BadName { stage: stage.name.clone(), what: what.to_string(), name: n.to_string() };
                if !package.is_empty() && !crate::pipeline::is_a_name(package) {
                    return Err(bad("package", package));
                }
                if !name.is_empty() && !crate::pipeline::is_a_name(name) {
                    return Err(bad("target name", name));
                }
                if name.is_empty() && *target != crate::pipeline::RunTarget::Test {
                    return Err(bad("target name", "(a Bench / Bin target names its bench or bin)"));
                }
            }
            if let crate::pipeline::Step::LiveTrace { functions, seconds, .. } = step {
                let bad = |n: &str| ValidationError::BadName { stage: stage.name.clone(), what: "probed function".to_string(), name: n.to_string() };
                if functions.is_empty() || functions.len() > 64 || *seconds > 600 {
                    return Err(bad("(1..=64 functions, at most 600 seconds)"));
                }
                // a path of identifiers (`a::b::c`, a trailing `*`): nothing a probe
                // script could read as code
                for f in functions {
                    let ok = !f.is_empty()
                        && f.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b':' || b == b'*')
                        && !f[..f.len() - 1].contains('*');
                    if !ok {
                        return Err(bad(f));
                    }
                }
            }
            if let crate::pipeline::Step::Sandbox { image, cmd, env, mounts, tmpfs, workdir, .. } = step {
                let bad = |why: String| ValidationError::BadSandbox { stage: stage.name.clone(), why };
                if !crate::pipeline::image_by_digest(image) {
                    return Err(bad(format!("image `{image}` is not named by digest (<name>@sha256:<hex>)")));
                }
                if cmd.is_empty() || cmd[0].is_empty() {
                    return Err(bad("an empty argv: a Sandbox runs a program".to_string()));
                }
                for m in mounts {
                    if !crate::pipeline::is_plain_absolute(&m.host) || !crate::pipeline::is_plain_absolute(&m.path) {
                        return Err(bad(format!("mount `{}` -> `{}` must be absolute on both sides, without `..`", m.host, m.path)));
                    }
                }
                for t in tmpfs {
                    if !crate::pipeline::is_plain_absolute(&t.path) || t.size_mb == 0 {
                        return Err(bad(format!("tmpfs mount `{}` must be absolute and sized", t.path)));
                    }
                }
                if !workdir.is_empty() && !crate::pipeline::is_plain_absolute(workdir) {
                    return Err(bad(format!("workdir `{workdir}` must be absolute")));
                }
                if let Some((k, _)) = env.iter().find(|(k, _)| crate::pipeline::env_looks_secret(k)) {
                    return Err(bad(format!("env `{k}` looks secret: nothing secret reaches a sandbox")));
                }
            }
            if let crate::pipeline::Step::Forge { action, .. } = step {
                if !crate::pipeline::is_a_name(action) {
                    return Err(ValidationError::BadName { stage: stage.name.clone(), what: "Forge action".to_string(), name: action.clone() });
                }
            }
            // a BuildWorkspace names a workspace and members, never paths
            if let crate::pipeline::Step::BuildWorkspace { workspace, repos, .. } = step {
                let bad = |what: &str, name: &str| ValidationError::BadName { stage: stage.name.clone(), what: what.to_string(), name: name.to_string() };
                if !crate::pipeline::is_a_name(workspace) {
                    return Err(bad("workspace", workspace));
                }
                if let Some(r) = repos.iter().find(|r| !crate::pipeline::is_a_name(r)) {
                    return Err(bad("repo", r));
                }
            }
        }
    }

    // 1c. `runs_on` must name a place a runner exists for — the pipeline's
    //     default and every stage override, by name.
    if !pipeline.runs_on.has_backend() {
        return Err(ValidationError::RunsOnNotWired { stage: None, runs_on: pipeline.runs_on });
    }
    for stage in &pipeline.stages {
        if let Some(r) = stage.runs_on.filter(|r| !r.has_backend()) {
            return Err(ValidationError::RunsOnNotWired { stage: Some(stage.name.clone()), runs_on: r });
        }
    }

    // 2. Every `needs` must reference a declared stage.
    for stage in &pipeline.stages {
        for need in &stage.needs {
            if !names.contains(need.as_str()) {
                return Err(ValidationError::UnknownNeed {
                    stage: stage.name.clone(),
                    needs: need.clone(),
                });
            }
        }
    }

    // 3. Cycle check — project onto a funnel-core PlanDag and reuse its topo
    //    engine. Edge direction is `need → stage` (dependency before dependent),
    //    matching `topo_order`'s contract.
    let nodes: Vec<PlanDagNode> = pipeline
        .stages
        .iter()
        .map(|s| PlanDagNode {
            id: s.name.clone(),
            title: s.name.clone(),
            group: None,
            status: DagStatus::Idle,
            is_bug: false,
            staleness_hours: 0.0,
            weight_bytes: 0,
            fanout: 0,
        })
        .collect();
    let edges: Vec<PlanDagEdge> = pipeline
        .stages
        .iter()
        .flat_map(|s| {
            s.needs.iter().map(move |need| PlanDagEdge {
                from: need.clone(),
                to: s.name.clone(),
                kind: EdgeKind::Dep,
            })
        })
        .collect();
    let dag = PlanDag {
        nodes,
        edges,
        sel_plan: None,
    };

    if let Err(cycle_nodes) = dag.topo_order() {
        return Err(ValidationError::Cycle {
            nodes: cycle_nodes,
        });
    }

    Ok(())
}