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(())
}