use crate::engine::FunctionRegistry;
use crate::errors::{FieldError, OrionError};
use crate::storage::repositories::workflows::{CreateWorkflowRequest, UpdateWorkflowRequest};
use super::common::{uncompiled_source_errors, validate_description, validate_id, validate_name};
pub fn validate_create_workflow(
req: &CreateWorkflowRequest,
max_loop_iterations: i64,
functions: &FunctionRegistry,
) -> Result<(), OrionError> {
if let Some(ref id) = req.workflow_id {
validate_id(id, "workflow.workflow_id")?;
}
validate_name(&req.name, "workflow.name")?;
if let Some(ref desc) = req.description {
validate_description(desc, "workflow.description")?;
}
let source = source_form_errors(
Some(&req.tasks),
Some(&req.condition),
req.loop_config.as_ref(),
);
if !source.is_empty() {
return Err(uncompiled(source));
}
let task_errors = validate_workflow_tasks_schema(&req.tasks, functions);
if !task_errors.is_empty() {
return Err(validation_with_details(
"Workflow tasks contain invalid function inputs",
task_errors,
));
}
reject_stray_secret_references(&req.tasks, functions)?;
if let Some(loop_config) = &req.loop_config {
let loop_errors = validate_workflow_loop_schema(loop_config, max_loop_iterations);
if !loop_errors.is_empty() {
return Err(validation_with_details(
"Workflow loop is invalid",
loop_errors,
));
}
}
Ok(())
}
fn reject_stray_secret_references(
tasks: &Value,
functions: &FunctionRegistry,
) -> Result<(), OrionError> {
let refs: Vec<FieldError> = secret_reference_errors(tasks, functions)
.into_iter()
.map(|(path, message)| FieldError::new(path, "UNRESOLVED_SECRET_REF", message))
.collect();
if refs.is_empty() {
return Ok(());
}
Err(validation_with_details(
"Workflow sends a secret reference somewhere it is never resolved",
refs,
))
}
pub fn validate_update_workflow(
req: &UpdateWorkflowRequest,
max_loop_iterations: i64,
functions: &FunctionRegistry,
) -> Result<(), OrionError> {
if let Some(ref name) = req.name {
validate_name(name, "workflow.name")?;
}
if let Some(ref desc) = req.description {
validate_description(desc, "workflow.description")?;
}
let source = source_form_errors(
req.tasks.as_ref(),
req.condition.as_ref(),
req.loop_config.as_ref(),
);
if !source.is_empty() {
return Err(uncompiled(source));
}
if let Some(ref tasks) = req.tasks {
let task_errors = validate_workflow_tasks_schema(tasks, functions);
if !task_errors.is_empty() {
return Err(validation_with_details(
"Workflow tasks contain invalid function inputs",
task_errors,
));
}
reject_stray_secret_references(tasks, functions)?;
}
if let Some(loop_config) = &req.loop_config
&& !loop_config.is_null()
{
let loop_errors = validate_workflow_loop_schema(loop_config, max_loop_iterations);
if !loop_errors.is_empty() {
return Err(validation_with_details(
"Workflow loop is invalid",
loop_errors,
));
}
}
Ok(())
}
pub fn validate_workflow_loop_schema(
loop_config: &serde_json::Value,
max_loop_iterations: i64,
) -> Vec<FieldError> {
let Some(obj) = loop_config.as_object() else {
return vec![FieldError::new(
"loop",
"INVALID",
"Workflow 'loop' must be an object with at least a 'max' — or absent, \
which runs the task list exactly once",
)];
};
let mut errors = Vec::new();
let init = match obj.get("init") {
None => Some(0),
Some(v) => match v.as_i64() {
Some(n) => Some(n),
None => {
errors.push(FieldError::new(
"loop.init",
"INVALID",
"Loop 'init' must be an integer — it is the counter's first value \
(default 0)",
));
None
}
},
};
match obj.get("increment") {
None => {}
Some(v) => match v.as_i64() {
Some(n) if n < 1 => errors.push(FieldError::new(
"loop.increment",
"INVALID",
format!(
"Loop 'increment' must be at least 1, got {n} — a counter that does \
not advance would never reach 'max'"
),
)),
Some(_) => {}
None => errors.push(FieldError::new(
"loop.increment",
"INVALID",
"Loop 'increment' must be an integer of at least 1 (default 1)",
)),
},
}
match obj.get("max") {
None => errors.push(FieldError::new(
"loop.max",
"REQUIRED",
"Loop 'max' is required — it is the upper bound that makes termination \
structural rather than a property of the condition being written correctly",
)),
Some(v) => match v.as_i64() {
Some(max) => {
if let Some(init) = init
&& max <= init
{
errors.push(FieldError::new(
"loop.max",
"INVALID",
format!(
"Loop 'max' ({max}) must be greater than 'init' ({init}) — the \
bound is half-open, so this could never run a sweep"
),
));
}
if max_loop_iterations > 0 && max > max_loop_iterations {
errors.push(FieldError::new(
"loop.max",
"INVALID",
format!(
"Loop 'max' ({max}) exceeds the configured ceiling of \
{max_loop_iterations} — raise engine.max_loop_iterations if this \
workload genuinely needs more sweeps"
),
));
}
}
None => errors.push(FieldError::new(
"loop.max",
"INVALID",
"Loop 'max' must be an integer",
)),
},
}
if let Some(counter) = obj.get("counter")
&& !counter.is_null()
{
match counter.as_str() {
Some(path) if path.is_empty() || path.split('.').any(str::is_empty) => {
errors.push(FieldError::new(
"loop.counter",
"INVALID",
format!(
"Loop 'counter' must be a non-empty temp_data field path, got \
{path:?} — \"i\" writes temp_data.i, and dots nest"
),
));
}
Some(_) => {}
None => errors.push(FieldError::new(
"loop.counter",
"INVALID",
"Loop 'counter' must be a string naming a temp_data field, or absent to \
bound the loop without exposing the count",
)),
}
}
errors
}
pub fn validate_workflow_tasks_schema(
tasks: &serde_json::Value,
functions: &FunctionRegistry,
) -> Vec<FieldError> {
if tasks.as_array().is_none() {
return Vec::new();
}
let mut errors = Vec::new();
let steps = crate::engine::walk_steps(tasks);
let mut seen_ids: std::collections::HashSet<&str> = std::collections::HashSet::new();
for path in &steps.too_deep {
errors.push(FieldError::new(
format!("{path}.tasks"),
"INVALID",
format!(
"Task groups nest more than {} deep — the engine refuses to build \
this workflow, which fails the whole reload rather than just this \
workflow",
crate::engine::MAX_STEP_DEPTH
),
));
}
for (path, group) in &steps.groups {
check_step_id(group, path, &mut seen_ids, &mut errors);
match group.get("tasks").and_then(|t| t.as_array()) {
Some(inner) if !inner.is_empty() => {}
Some(_) => errors.push(FieldError::new(
format!("{path}.tasks"),
"INVALID",
"A task group must contain at least one task — an empty group is a \
condition guarding nothing",
)),
None => errors.push(FieldError::new(
format!("{path}.tasks"),
"TYPE_MISMATCH",
"A task group's 'tasks' must be an array of steps",
)),
}
check_terminal(group, path, &mut errors);
}
for (path, task) in &steps.tasks {
check_step_id(task, path, &mut seen_ids, &mut errors);
if task.get("name").and_then(|v| v.as_str()).is_none() {
errors.push(FieldError::new(
format!("{path}.name"),
"REQUIRED",
"Task 'name' is required and must be a string — it is what makes \
an audit trail or a trace readable to a human. It may be empty, \
but it must be present: without the key this workflow would be \
accepted and then fail to load, taking its channel out of service",
));
}
check_terminal(task, path, &mut errors);
let function = task.get("function");
let fn_name = function
.and_then(|f| f.get("name"))
.and_then(|n| n.as_str())
.unwrap_or("");
if fn_name.is_empty() {
errors.push(FieldError::new(
format!("{path}.function.name"),
"REQUIRED",
"Task 'function' with a non-empty 'name' is required — the engine's \
task shape has no default for it, so without one this workflow \
would be accepted and then fail to build",
));
continue;
}
if !functions.contains(fn_name) {
let suggestion = functions
.suggest(fn_name)
.map(|closest| format!(" — did you mean '{closest}'?"))
.unwrap_or_default();
errors.push(FieldError::new(
format!("{path}.function.name"),
"UNKNOWN_FUNCTION",
format!(
"Unknown function '{fn_name}'{suggestion} — this workflow would be \
accepted and then fail at its first request"
),
));
continue;
}
let input = function
.and_then(|f| f.get("input"))
.cloned()
.unwrap_or(serde_json::Value::Object(Default::default()));
errors.extend(functions.validate_input(fn_name, &input, path));
}
if errors.is_empty() {
let synthetic = serde_json::json!({
"id": "__shape_check__", "name": "__shape_check__",
"condition": true, "tasks": tasks,
});
errors.extend(
dataflow_rs::Workflow::validate_authored(&synthetic)
.into_iter()
.filter(|issue| issue.severity() != dataflow_rs::Severity::Advisory)
.map(engine_issue_to_field_error),
);
}
errors
}
fn engine_issue_to_field_error(issue: dataflow_rs::WorkflowIssue) -> FieldError {
use dataflow_rs::IssueCode;
let code = match issue.code {
IssueCode::NoTasks | IssueCode::MissingStepId | IssueCode::MissingFunction => "REQUIRED",
IssueCode::DuplicateStepId => "DUPLICATE_TASK_ID",
IssueCode::InvalidTerminal => "TYPE_MISMATCH",
IssueCode::UnknownFunction | IssueCode::MissingHandler => "UNKNOWN_FUNCTION",
_ => "INVALID",
};
FieldError::new(
issue.path.unwrap_or_else(|| "tasks".to_string()),
code,
issue.message,
)
}
fn source_form_errors(
tasks: Option<&serde_json::Value>,
condition: Option<&serde_json::Value>,
loop_config: Option<&serde_json::Value>,
) -> Vec<FieldError> {
let mut errors = Vec::new();
if let Some(tasks) = tasks {
errors.extend(uncompiled_source_errors(tasks, "tasks"));
}
if let Some(condition) = condition {
errors.extend(uncompiled_source_errors(condition, "condition"));
}
if let Some(loop_config) = loop_config {
errors.extend(uncompiled_source_errors(loop_config, "loop"));
}
errors
}
fn uncompiled(details: Vec<FieldError>) -> OrionError {
validation_with_details(
"Workflow has not been compiled: it still contains shared-definition references",
details,
)
}
fn validation_with_details(message: &str, details: Vec<FieldError>) -> OrionError {
OrionError::Validation {
code: "VALIDATION_ERROR",
message: message.to_string(),
details,
}
}
pub fn validate_workflow_id(id: &str) -> Result<(), OrionError> {
validate_id(id, "workflow.workflow_id")
}
fn check_step_id<'a>(
step: &'a serde_json::Value,
path: &str,
seen: &mut std::collections::HashSet<&'a str>,
errors: &mut Vec<FieldError>,
) {
let raw = step
.get("id")
.and_then(serde_json::Value::as_str)
.unwrap_or("");
if raw.trim().is_empty() {
errors.push(FieldError::new(
format!("{path}.id"),
"REQUIRED",
"Step 'id' is required and must be a non-empty string — it names \
the step in audit trails, execution traces, per-task metrics and \
`metadata.progress`, which workflow conditions can read. Without \
one this workflow would be accepted and then fail to load, \
taking its channel out of service",
));
return;
}
if !seen.insert(raw) {
errors.push(FieldError::new(
format!("{path}.id"),
"DUPLICATE_TASK_ID",
format!(
"Duplicate step id '{raw}' — ids must be unique within a \
workflow, across tasks and task groups alike. The engine \
refuses to build one that repeats them, so this fails the \
entire engine reload rather than just this workflow"
),
));
}
}
fn check_terminal(step: &serde_json::Value, path: &str, errors: &mut Vec<FieldError>) {
if let Some(terminal) = step.get("terminal")
&& !terminal.is_boolean()
{
errors.push(FieldError::new(
format!("{path}.terminal"),
"TYPE_MISMATCH",
"'terminal' must be a boolean",
));
}
}
use serde_json::Value;
pub fn engine_advisories(tasks: &Value, functions: &FunctionRegistry) -> Vec<EngineAdvisory> {
let synthetic = serde_json::json!({
"id": "__shape_check__", "name": "__shape_check__",
"condition": true, "tasks": tasks,
});
let Ok(workflow) = dataflow_rs::Workflow::from_json(&synthetic.to_string()) else {
return Vec::new();
};
let issues = dataflow_rs::Engine::builder().check_workflow(&workflow);
let mut out: Vec<EngineAdvisory> = issues
.iter()
.filter(|issue| issue.code == dataflow_rs::IssueCode::EscapedTemplateKey)
.filter(|issue| issue.path.as_deref().is_none_or(is_accidental_escape))
.map(|issue| EngineAdvisory {
check: EngineAdvisory::ESCAPED_TEMPLATE_KEY,
path: issue.path.clone().unwrap_or_else(|| "tasks".to_string()),
message: issue.message.clone(),
})
.collect();
for_each_input_field(tasks, |function, field, path, value| {
if dataflow_rs::is_builtin_function(function) {
return;
}
if !functions.template_paths(function, field).contains(&"") {
return;
}
out.extend(
escaped_keys_in(value)
.into_iter()
.map(|(suffix, message)| EngineAdvisory {
check: EngineAdvisory::ESCAPED_TEMPLATE_KEY,
path: format!("{path}{suffix}"),
message,
}),
);
});
out.extend(tensor_operator_key_advisories(
tasks,
functions,
TensorKeyScope::Constant,
));
out.extend(issues.into_iter().filter_map(|issue| {
if issue.severity() != dataflow_rs::Severity::Advisory
|| issue.code == dataflow_rs::IssueCode::EscapedTemplateKey
{
return None;
}
let (check, noun) = match issue.code {
dataflow_rs::IssueCode::UnguardedValidation => {
(EngineAdvisory::UNGUARDED_VALIDATION, "task")
}
dataflow_rs::IssueCode::GroupContinueOnError => {
(EngineAdvisory::GROUP_CONTINUE_ON_ERROR, "group")
}
_ => (EngineAdvisory::UNCLASSIFIED, "task"),
};
let path = match (&issue.task_id, &issue.path) {
(Some(id), Some(field)) => format!("{noun} '{id}'.{field}"),
(Some(id), None) => format!("{noun} '{id}'"),
(None, Some(field)) => field.clone(),
(None, None) => "tasks".to_string(),
};
Some(EngineAdvisory {
check,
path,
message: issue.message,
})
}));
out
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EngineAdvisory {
pub check: &'static str,
pub path: String,
pub message: String,
}
impl EngineAdvisory {
pub const ESCAPED_TEMPLATE_KEY: &'static str = "logic.escaped_template_key";
pub const UNGUARDED_VALIDATION: &'static str = "engine.unguarded_validation";
pub const GROUP_CONTINUE_ON_ERROR: &'static str = "engine.group_continue_on_error";
pub const TENSOR_OPERATOR_KEY: &'static str = "logic.tensor_operator_key";
pub const UNCLASSIFIED: &'static str = "engine.advisory";
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TensorKeyScope {
Constant,
Dynamic,
}
pub fn tensor_operator_key_advisories(
tasks: &Value,
functions: &FunctionRegistry,
scope: TensorKeyScope,
) -> Vec<EngineAdvisory> {
let mut out = Vec::new();
for_each_input_field(tasks, |function, field, path, value| {
if function == "map" {
if field != "mappings" {
return;
}
let Some(mappings) = value.as_array() else {
return;
};
for (i, mapping) in mappings.iter().enumerate() {
if let Some(logic) = mapping.get("logic") {
collect_tensor_keys(logic, &format!("{path}[{i}].logic"), scope, &mut out);
}
}
return;
}
let template_paths = functions.template_paths(function, field);
if template_paths.contains(&"") {
collect_tensor_keys(value, path, scope, &mut out);
} else if template_paths.contains(&"*")
&& let Some(members) = value.as_object()
{
for (member, v) in members {
collect_tensor_keys(v, &format!("{path}.{member}"), scope, &mut out);
}
}
});
out
}
fn collect_tensor_keys(
value: &Value,
path: &str,
scope: TensorKeyScope,
out: &mut Vec<EngineAdvisory>,
) {
match value {
Value::Object(map) => {
if map.len() == 1 {
let (key, inner) = map.iter().next().expect("one entry");
if crate::engine::operators::is_tensor_operator(key) {
let dynamic = reads_the_context(inner);
let report = match scope {
TensorKeyScope::Constant => !dynamic && !evaluates_as_a_call(value),
TensorKeyScope::Dynamic => dynamic,
};
if report {
out.push(EngineAdvisory {
check: EngineAdvisory::TENSOR_OPERATOR_KEY,
path: format!("{path}.{key}"),
message: format!(
"`{key}` names a tensor operator since 1.8, so this object is \
evaluated as a call to it rather than emitted as data; spell \
the key `${key}` to keep the literal"
),
});
}
return;
}
collect_tensor_keys(inner, &format!("{path}.{key}"), scope, out);
return;
}
for (key, inner) in map {
collect_tensor_keys(inner, &format!("{path}.{key}"), scope, out);
}
}
Value::Array(items) => {
for (i, item) in items.iter().enumerate() {
collect_tensor_keys(item, &format!("{path}[{i}]"), scope, out);
}
}
_ => {}
}
}
fn reads_the_context(value: &Value) -> bool {
match value {
Value::Object(map) => {
if map.len() == 1
&& let Some(key) = map.keys().next()
&& crate::engine::operators::is_operator(key)
{
return true;
}
map.values().any(reads_the_context)
}
Value::Array(items) => items.iter().any(reads_the_context),
_ => false,
}
}
fn evaluates_as_a_call(value: &Value) -> bool {
use dataflow_rs::datalogic_rs as datalogic;
static ENGINE: std::sync::OnceLock<datalogic::Engine> = std::sync::OnceLock::new();
let engine = ENGINE.get_or_init(|| {
crate::engine::operators::add_to_datalogic(datalogic::Engine::builder()).build()
});
let Ok(compiled) = engine.compile(value) else {
return false;
};
engine
.session()
.eval_into::<Value, _>(&compiled, &Value::Object(Default::default()))
.is_ok()
}
fn escaped_keys_in(value: &Value) -> Vec<(String, String)> {
const PREFIX: &str = "function.input.mappings[0].logic";
let synthetic = serde_json::json!({
"id": "__template_key_check__", "name": "__template_key_check__",
"condition": true,
"tasks": [{
"id": "t", "name": "t",
"function": { "name": "map", "input": { "mappings": [
{ "path": "data.__probe__", "logic": value }
] } }
}],
});
let Ok(workflow) = dataflow_rs::Workflow::from_json(&synthetic.to_string()) else {
return Vec::new();
};
let builder = dataflow_rs::Engine::builder();
builder
.check_workflow(&workflow)
.into_iter()
.filter(|issue| issue.code == dataflow_rs::IssueCode::EscapedTemplateKey)
.filter_map(|issue| {
let path = issue.path?;
let suffix = path.strip_prefix(PREFIX)?;
is_accidental_escape(&path).then(|| (suffix.to_string(), issue.message))
})
.collect()
}
fn is_accidental_escape(path: &str) -> bool {
let key = path.rsplit('.').next().unwrap_or(path);
key.starts_with('$') && !key.starts_with("$$")
}
pub fn unresolvable_logic_warnings(
tasks: &Value,
functions: &FunctionRegistry,
) -> Vec<(String, String)> {
let mut out = Vec::new();
for_each_input_field(tasks, |function, field, path, value| {
if functions.is_resolvable_field(function, field) {
collect_unresolvable(value, path, function, &mut out);
}
});
out
}
fn for_each_input_field(tasks: &Value, mut visit: impl FnMut(&str, &str, &str, &Value)) {
for (path, task) in crate::engine::walk_steps(tasks).tasks {
let Some(function) = task.get("function") else {
continue;
};
let (Some(name), Some(input)) = (
function.get("name").and_then(Value::as_str),
function.get("input").and_then(Value::as_object),
) else {
continue;
};
for (field, value) in input {
visit(
name,
field,
&format!("{path}.function.input.{field}"),
value,
);
}
}
}
pub fn secret_reference_errors(
tasks: &Value,
functions: &FunctionRegistry,
) -> Vec<(String, String)> {
let mut out = Vec::new();
for_each_input_field(tasks, |function, field, path, value| {
let exempt = functions.secret_paths(function, field);
let plugin = functions
.get(function)
.is_some_and(|e| e.source == crate::engine::functions::schema::Source::Plugin);
let inspects_nodes =
plugin || functions.is_resolvable_field(function, field) || !exempt.is_empty();
collect_secret_references(
value,
path,
"",
exempt,
function,
inspects_nodes,
plugin,
&mut out,
);
});
out
}
#[allow(clippy::too_many_arguments)]
fn collect_secret_references(
value: &Value,
path: &str,
rel: &str,
exempt: &[&str],
function: &str,
inspects_nodes: bool,
plugin: bool,
out: &mut Vec<(String, String)>,
) {
if exempt.contains(&rel) {
return;
}
if plugin && let Some(name) = crate::engine::functions::secret_ref::secret_name(value) {
out.push((
path.to_string(),
format!(
"'{function}' is a plugin function, and a plugin never sees key material: \
{{\"secret\": \"{name}\"}} is refused anywhere in its input. Read the secret \
in a built-in that takes one, or pass the plugin a value derived from it."
),
));
return;
}
if inspects_nodes && let Some(name) = crate::engine::functions::secret_ref::secret_name(value) {
out.push((
path.to_string(),
format!(
"'{function}' does not read key material in this field, so \
{{\"secret\": \"{name}\"}} is stored and sent on as that object rather \
than resolved. Secrets are read only where a function takes a key; \
for a deployment value elsewhere, declare it under [vars] and read it \
as {{\"var\": \"metadata.vars.<name>\"}}."
),
));
return;
}
match value {
Value::String(s) => {
if crate::connector::secrets::is_resolvable_reference(s) {
out.push((
path.to_string(),
format!(
"'{function}' does not resolve secret references in this field, so \
'{s}' is sent on as that literal text. Move the value to a \
connector, or declare it in the config file — a deployment value \
under [vars], read as {{\"var\": \"metadata.vars.<name>\"}}, and key \
material under [secrets], read as {{\"secret\": \"<name>\"}} in one \
of the fields that take it."
),
));
}
}
Value::Object(map) => {
for (key, child) in map {
collect_secret_references(
child,
&format!("{path}.{key}"),
&format!("{rel}.{key}"),
exempt,
function,
inspects_nodes,
plugin,
out,
);
}
}
Value::Array(items) => {
for (i, child) in items.iter().enumerate() {
collect_secret_references(
child,
&format!("{path}[{i}]"),
&format!("{rel}[]"),
exempt,
function,
inspects_nodes,
plugin,
out,
);
}
}
_ => {}
}
}
fn collect_unresolvable(
value: &Value,
path: &str,
function: &str,
out: &mut Vec<(String, String)>,
) {
match value {
Value::Object(map) => {
if map.len() == 1 {
let (key, arg) = map.iter().next().expect("len checked");
if key == "var" {
return;
}
if key == "val" {
out.push((
path.to_string(),
format!(
"'{function}' folds {{\"var\": ..}} nodes only, so {{\"val\": ..}} is \
stored verbatim — write {{\"var\": ..}} here"
),
));
return;
}
if arg.is_array() && crate::engine::operators::is_operator(key) {
out.push((
path.to_string(),
format!(
"'{function}' folds {{\"var\": ..}} nodes only, so the '{key}' \
expression here is never evaluated — it is written through as a \
literal object. Compute it in a 'map' task first and reference the \
result with {{\"var\": ..}}."
),
));
return;
}
}
for (key, child) in map {
collect_unresolvable(child, &format!("{path}.{key}"), function, out);
}
}
Value::Array(items) => {
for (i, child) in items.iter().enumerate() {
collect_unresolvable(child, &format!("{path}[{i}]"), function, out);
}
}
_ => {}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn registry() -> &'static FunctionRegistry {
FunctionRegistry::builtin()
}
fn validate_create_workflow(req: &CreateWorkflowRequest, cap: i64) -> Result<(), OrionError> {
super::validate_create_workflow(req, cap, registry())
}
fn validate_update_workflow(req: &UpdateWorkflowRequest, cap: i64) -> Result<(), OrionError> {
super::validate_update_workflow(req, cap, registry())
}
fn unresolvable_logic_warnings(tasks: &Value) -> Vec<(String, String)> {
super::unresolvable_logic_warnings(tasks, registry())
}
fn secret_reference_errors(tasks: &Value) -> Vec<(String, String)> {
super::secret_reference_errors(tasks, registry())
}
#[test]
fn a_plugin_function_refuses_a_secret_node_in_every_field() {
use crate::engine::functions::schema::{FieldKind, RetrySafety, Source, WriteShape};
use crate::engine::{FieldSpec, FunctionEntry, PluginBinding};
let field =
|name: &str, template_at: &'static [&'static str], resolvable: bool| FieldSpec {
name: name.to_string(),
description: String::new(),
kind: FieldKind::Any,
required: false,
resolvable,
secret_at: &[],
template_at,
alias: None,
};
let registry = FunctionRegistry::builtin()
.with_entries(vec![FunctionEntry {
name: "acme.codec.parse".to_string(),
description: String::new(),
category: "transform".to_string(),
source: Source::Plugin,
aliases: Vec::new(),
input_fields: Some(vec![
field("template", &[""], false),
field("folded", &[], true),
field("literal", &[], false),
field("output", &[], false),
]),
writes: WriteShape::OutputPath { default_root: None },
retry_safety: RetrySafety::Pure,
deny_unknown: true,
validate_static: None,
connector: None,
plugin: Some(PluginBinding {
id: "acme.codec".to_string(),
version: 1,
digest: "sha256:00".to_string(),
abi: "orion:plugin@1.0.0".to_string(),
}),
}])
.expect("extends");
let found = super::secret_reference_errors(
&json!([{
"id": "t", "name": "t",
"function": {"name": "acme.codec.parse", "input": {
"template": {"cat": ["k=", {"secret": "api_key"}]},
"folded": {"secret": "api_key"},
"literal": {"nested": [{"secret": "api_key"}]},
"output": "data.out"
}}
}]),
®istry,
);
let mut paths: Vec<&str> = found.iter().map(|(p, _)| p.as_str()).collect();
paths.sort_unstable();
assert_eq!(
paths,
[
"tasks[0].function.input.folded",
"tasks[0].function.input.literal.nested[0]",
"tasks[0].function.input.template.cat[1]",
],
"{found:?}"
);
assert!(
found
.iter()
.all(|(_, m)| m.contains("never sees key material")),
"{found:?}"
);
}
use crate::validation::common::MAX_DESCRIPTION_LEN;
use serde_json::json;
fn one_task() -> serde_json::Value {
json!([{
"id": "t1", "name": "t1",
"function": {"name": "map", "input": {"mappings": []}}
}])
}
#[test]
fn test_validate_create_workflow_full() {
let req = CreateWorkflowRequest {
workflow_id: Some("my-workflow-1".to_string()),
name: "Test Workflow".to_string(),
description: Some("A test workflow".to_string()),
priority: 10,
condition: json!(true),
tasks: one_task(),
tags: vec!["tag1".to_string()],
loop_config: None,
continue_on_error: false,
};
assert!(validate_create_workflow(&req, 10_000).is_ok());
}
#[test]
fn test_validate_create_workflow_invalid_id() {
let req = CreateWorkflowRequest {
workflow_id: Some("bad id with spaces".to_string()),
name: "Test Workflow".to_string(),
description: None,
priority: 0,
condition: json!(true),
tasks: one_task(),
tags: vec![],
loop_config: None,
continue_on_error: false,
};
assert!(validate_create_workflow(&req, 10_000).is_err());
}
#[test]
fn test_validate_create_workflow_long_description() {
let req = CreateWorkflowRequest {
workflow_id: None,
name: "Test Workflow".to_string(),
description: Some("d".repeat(MAX_DESCRIPTION_LEN + 1)),
priority: 0,
condition: json!(true),
tasks: one_task(),
tags: vec![],
loop_config: None,
continue_on_error: false,
};
assert!(validate_create_workflow(&req, 10_000).is_err());
}
#[test]
fn test_validate_update_workflow_all_fields() {
let req = UpdateWorkflowRequest {
name: Some("Updated Name".to_string()),
description: Some("Updated desc".to_string()),
priority: Some(5),
condition: None,
tasks: None,
tags: None,
loop_config: None,
continue_on_error: None,
};
assert!(validate_update_workflow(&req, 10_000).is_ok());
}
#[test]
fn test_validate_update_workflow_invalid_name() {
let req = UpdateWorkflowRequest {
name: Some("".to_string()),
description: None,
priority: None,
condition: None,
tasks: None,
tags: None,
loop_config: None,
continue_on_error: None,
};
assert!(validate_update_workflow(&req, 10_000).is_err());
}
#[test]
fn test_validate_update_workflow_invalid_description() {
let req = UpdateWorkflowRequest {
name: None,
description: Some("x".repeat(MAX_DESCRIPTION_LEN + 1)),
priority: None,
condition: None,
tasks: None,
tags: None,
loop_config: None,
continue_on_error: None,
};
assert!(validate_update_workflow(&req, 10_000).is_err());
}
fn mongo_write_tasks(input: serde_json::Value) -> serde_json::Value {
serde_json::json!([{
"id": "w", "name": "Write",
"function": {"name": "mongo_write", "input": input}
}])
}
#[test]
fn an_expression_in_a_resolvable_field_is_reported() {
let warnings = unresolvable_logic_warnings(&mongo_write_tasks(serde_json::json!({
"connector": "db", "database": "app", "collection": "sessions",
"op": "update_one",
"filter": {"_id": {"var": "data.id"}},
"update": {"$set": {"expiresAt": {"cat": ["2026-", {"var": "data.month"}]}}}
})));
assert_eq!(warnings.len(), 1, "one finding: {warnings:?}");
assert_eq!(
warnings[0].0, "tasks[0].function.input.update.$set.expiresAt",
"the path must point at the node, not the field"
);
assert!(warnings[0].1.contains("'cat'"), "{}", warnings[0].1);
}
#[test]
fn a_val_node_is_reported_with_the_var_spelling() {
let warnings = unresolvable_logic_warnings(&mongo_write_tasks(serde_json::json!({
"connector": "db", "database": "app", "collection": "s",
"op": "insert_one",
"document": {"id": {"val": "data.id"}}
})));
assert_eq!(warnings.len(), 1, "{warnings:?}");
assert!(
warnings[0].1.contains("{\"var\": ..}"),
"the message must name the spelling that works: {}",
warnings[0].1
);
}
#[test]
fn an_expression_nested_under_extended_json_is_reported() {
let warnings = unresolvable_logic_warnings(&mongo_write_tasks(serde_json::json!({
"connector": "db", "database": "app", "collection": "s",
"op": "insert_one",
"document": {"createdAt": {"$date": {"now": []}}}
})));
assert_eq!(warnings.len(), 1, "{warnings:?}");
assert!(
warnings[0].0.ends_with("document.createdAt.$date"),
"path: {}",
warnings[0].0
);
}
#[test]
fn data_that_merely_looks_like_an_operator_is_not_reported() {
let clean = unresolvable_logic_warnings(&mongo_write_tasks(serde_json::json!({
"connector": "db", "database": "app", "collection": "s",
"op": "update_one",
"filter": {"$and": [{"_id": {"var": "data.id"}}, {"n": {"$lt": 5}}]},
"update": {"$set": {"video": {"length": 120}, "type": "clip"}}
})));
assert!(clean.is_empty(), "no findings expected, got {clean:?}");
}
#[test]
fn an_operator_in_an_array_element_of_a_resolvable_field_is_reported() {
let warnings = unresolvable_logic_warnings(&serde_json::json!([{
"id": "q", "name": "Query",
"function": {"name": "db_read", "input": {
"connector": "orders",
"query": "SELECT id FROM orders LIMIT $1",
"params": [{"or": [{"var": "data.req.limit"}, 50]}]
}}
}]));
assert_eq!(warnings.len(), 1, "got {warnings:?}");
assert_eq!(warnings[0].0, "tasks[0].function.input.params[0]");
assert!(warnings[0].1.contains("'or'"), "{}", warnings[0].1);
assert!(warnings[0].1.contains("'map' task"), "{}", warnings[0].1);
}
#[test]
fn a_json_document_bound_as_a_parameter_is_not_reported() {
let clean = unresolvable_logic_warnings(&serde_json::json!([{
"id": "q", "name": "Query",
"function": {"name": "db_read", "input": {
"connector": "orders",
"query": "SELECT id FROM orders WHERE meta @> $1",
"params": [{"tier": "gold", "length": 120}]
}}
}]));
assert!(clean.is_empty(), "got {clean:?}");
}
#[test]
fn a_non_resolvable_field_is_not_scanned() {
let clean = unresolvable_logic_warnings(&serde_json::json!([{
"id": "q", "name": "Query",
"function": {"name": "db_read", "input": {
"connector": "orders",
"sql": "SELECT 1 WHERE cat = 'if'"
}}
}]));
assert!(clean.is_empty(), "got {clean:?}");
}
#[test]
fn test_validate_workflow_id() {
assert!(validate_workflow_id("my-workflow-1").is_ok());
assert!(validate_workflow_id("bad id!").is_err());
}
#[test]
fn a_reference_outside_a_secret_field_is_reported() {
let found = secret_reference_errors(&serde_json::json!([{
"id": "call", "name": "Call",
"function": {"name": "http_call", "input": {
"connector": "crm",
"path": "env://API_BASE"
}}
}]));
assert_eq!(found.len(), 1, "got {found:?}");
assert_eq!(found[0].0, "tasks[0].function.input.path");
assert!(found[0].1.contains("env://API_BASE"), "{}", found[0].1);
}
#[test]
fn a_secret_node_outside_a_key_field_is_reported() {
let found = secret_reference_errors(&serde_json::json!([{
"id": "t1",
"function": {"name": "db_read", "input": {
"connector": "orders",
"query": "SELECT 1 WHERE token = $1",
"params": [{"secret": "api_key"}],
}},
}]));
assert_eq!(
found.len(),
1,
"expected exactly one finding, got {found:?}"
);
assert!(found[0].0.contains("params"), "{:?}", found[0]);
assert!(found[0].1.contains("api_key"), "{:?}", found[0]);
}
#[test]
fn the_array_spelling_of_a_secret_node_is_reported_too() {
let found = secret_reference_errors(&serde_json::json!([{
"id": "t1",
"function": {"name": "db_read", "input": {
"connector": "pg",
"query": "SELECT 1",
"params": [{"secret": ["api_key"]}],
}},
}]));
assert_eq!(
found.len(),
1,
"expected exactly one finding, got {found:?}"
);
}
#[test]
fn a_secret_node_in_an_evaluated_field_is_not_a_finding() {
let found = secret_reference_errors(&serde_json::json!([{
"id": "t1",
"function": {"name": "send_email", "input": {
"connector": "mail",
"subject": {"cat": ["token=", {"secret": "api_key"}]},
}},
}]));
assert!(found.is_empty(), "{found:?}");
}
#[test]
fn a_secret_node_in_a_key_field_is_left_alone() {
let clean = secret_reference_errors(&serde_json::json!([{
"id": "t1",
"function": {"name": "crypto", "input": {
"operation": "hmac_sha256",
"data": "payload",
"key": {"secret": "signing_key"},
}},
}]));
assert!(clean.is_empty(), "{clean:?}");
}
#[test]
fn the_secret_node_exemption_follows_the_path_not_the_field() {
let found = secret_reference_errors(&serde_json::json!([{
"id": "t1",
"function": {"name": "jwt_verify", "input": {
"token": "t",
"keys": [{"kid": {"secret": "which_key"}, "key": {"secret": "signing_key"}}],
}},
}]));
assert_eq!(
found.len(),
1,
"expected only the `kid` finding, got {found:?}"
);
assert!(found[0].0.contains("kid"), "{:?}", found[0]);
}
#[test]
fn a_column_named_secret_is_not_a_secret_node() {
let clean = secret_reference_errors(&serde_json::json!([{
"id": "t1",
"function": {"name": "data_query", "input": {
"connector": "orders",
"schema": {"entities": {"items": {
"physical": "items",
"columns": {"id": {"queryable": true}, "secret": {"queryable": false}},
}}},
"query": {"source": "items", "sort": [{"secret": "asc"}]},
"output": "data.result",
}},
}]));
assert!(clean.is_empty(), "{clean:?}");
}
#[test]
fn a_secret_node_in_data_querys_resolvable_params_is_still_reported() {
let found = secret_reference_errors(&serde_json::json!([{
"id": "t1",
"function": {"name": "data_query", "input": {
"connector": "orders",
"query": {"source": "items"},
"params": {"token": {"secret": "api_key"}},
"output": "data.result",
}},
}]));
assert_eq!(
found.len(),
1,
"expected exactly one finding, got {found:?}"
);
assert!(found[0].0.contains("params"), "{:?}", found[0]);
}
#[test]
fn a_multi_key_object_holding_a_secret_member_is_data() {
let clean = secret_reference_errors(&serde_json::json!([{
"id": "t1",
"function": {"name": "db_read", "input": {
"connector": "orders",
"query": "SELECT 1",
"params": [{"secret": "a", "public": "b"}],
}},
}]));
assert!(clean.is_empty(), "{clean:?}");
}
#[test]
fn a_reference_in_a_secret_field_is_left_alone() {
let clean = secret_reference_errors(&serde_json::json!([
{
"id": "mac", "name": "MAC",
"function": {"name": "crypto", "input": {
"op": "hmac", "key": "env://PARTNER_KEY", "data": {"var": "data.body"}
}}
},
{
"id": "jwt", "name": "Verify",
"function": {"name": "jwt_verify", "input": {
"token": {"var": "data.token"},
"keys": [{"algorithm": "HS256", "key": "env://JWT_SECRET"}],
"audience": "env://OAUTH_CLIENT_ID"
}}
}
]));
assert!(clean.is_empty(), "got {clean:?}");
}
#[test]
fn a_reference_in_a_sibling_of_a_key_is_still_reported() {
let found = secret_reference_errors(&serde_json::json!([{
"id": "jwt", "name": "Verify",
"function": {"name": "jwt_verify", "input": {
"token": {"var": "data.token"},
"keys": [{
"algorithm": "HS256",
"key": {"secret": "partner_hmac"},
"kid": "env://PARTNER_KID",
"key_encoding": "utf8"
}]
}}
}]));
assert_eq!(found.len(), 1, "got {found:?}");
assert_eq!(found[0].0, "tasks[0].function.input.keys[0].kid");
}
#[test]
fn a_reference_inside_a_task_group_is_reported() {
let found = secret_reference_errors(&serde_json::json!([{
"id": "guarded", "name": "Guarded",
"condition": {"var": "data.ok"},
"tasks": [{
"id": "send", "name": "Send",
"function": {"name": "send_email", "input": {
"connector": "smtp", "to": "ops@example.com",
"subject": "hi", "text": "vault://secret/data/x#y"
}}
}]
}]));
assert_eq!(found.len(), 1, "got {found:?}");
assert!(
found[0].0.ends_with("function.input.text"),
"{}",
found[0].0
);
}
#[test]
fn an_ordinary_url_is_not_a_reference() {
let clean = secret_reference_errors(&serde_json::json!([{
"id": "call", "name": "Call",
"function": {"name": "http_call", "input": {
"connector": "crm", "path": "https://example.com/v1/orders"
}}
}]));
assert!(clean.is_empty(), "got {clean:?}");
}
}
#[cfg(test)]
mod engine_advisory_tests {
fn engine_advisories(tasks: &serde_json::Value) -> Vec<super::EngineAdvisory> {
super::engine_advisories(tasks, crate::engine::FunctionRegistry::builtin())
}
use serde_json::json;
#[test]
fn an_unguarded_validation_is_reported() {
let tasks = json!([
{ "id": "check", "name": "Check", "function": { "name": "validation", "input": {
"rules": [{ "logic": { "==": [1, 2] }, "message": "no" }] } } },
{ "id": "respond", "name": "Respond", "function": { "name": "map", "input": {
"mappings": [{ "path": "data.x", "logic": true }] } } }
]);
let found = engine_advisories(&tasks);
assert_eq!(found.len(), 1);
assert_eq!(found[0].check, "engine.unguarded_validation");
assert!(found[0].message.contains("halt_on"), "{}", found[0].message);
assert_eq!(found[0].path, "task 'check'.halt_on");
}
#[test]
fn halt_on_silences_it() {
let tasks = json!([
{ "id": "check", "name": "Check", "halt_on": "failure",
"function": { "name": "validation", "input": {
"rules": [{ "logic": { "==": [1, 2] }, "message": "no" }] } } },
{ "id": "respond", "name": "Respond", "function": { "name": "map", "input": {
"mappings": [{ "path": "data.x", "logic": true }] } } }
]);
assert!(engine_advisories(&tasks).is_empty());
}
#[test]
fn a_guarded_successor_silences_it() {
let tasks = json!([
{ "id": "check", "name": "Check", "function": { "name": "validation", "input": {
"rules": [{ "logic": { "==": [1, 2] }, "message": "no" }] } } },
{ "id": "respond", "name": "Respond",
"condition": { "==": [{ "var": "metadata.progress.status_code" }, 200] },
"function": { "name": "map", "input": {
"mappings": [{ "path": "data.x", "logic": true }] } } }
]);
assert!(engine_advisories(&tasks).is_empty());
}
#[test]
fn continue_on_error_on_a_group_is_reported() {
let tasks = json!([
{ "id": "g", "name": "G", "continue_on_error": true, "tasks": [
{ "id": "t", "name": "T", "function": { "name": "log", "input": {
"message": "x" } } } ] }
]);
let found = engine_advisories(&tasks);
assert_eq!(found.len(), 1);
assert_eq!(found[0].check, "engine.group_continue_on_error");
assert_eq!(found[0].path, "group 'g'.continue_on_error");
}
#[test]
fn an_ordinary_workflow_is_quiet() {
let tasks = json!([
{ "id": "t", "name": "T", "function": { "name": "log", "input": {
"message": "x" } } }
]);
assert!(engine_advisories(&tasks).is_empty());
}
}
#[cfg(test)]
mod tensor_operator_key_tests {
use super::{EngineAdvisory, TensorKeyScope, tensor_operator_key_advisories};
use serde_json::{Value, json};
fn constant(tasks: &Value) -> Vec<EngineAdvisory> {
tensor_operator_key_advisories(
tasks,
crate::engine::FunctionRegistry::builtin(),
TensorKeyScope::Constant,
)
}
fn dynamic(tasks: &Value) -> Vec<EngineAdvisory> {
tensor_operator_key_advisories(
tasks,
crate::engine::FunctionRegistry::builtin(),
TensorKeyScope::Dynamic,
)
}
fn mapping(logic: Value) -> Value {
json!([{ "id": "m", "name": "M", "function": { "name": "map", "input": {
"mappings": [{ "path": "data.out", "logic": logic }] } } }])
}
#[test]
fn a_constant_literal_named_after_a_tensor_operator_is_reported() {
let found = constant(&mapping(json!({ "shape": [6, 7] })));
assert_eq!(found.len(), 1, "{found:?}");
assert_eq!(found[0].check, "logic.tensor_operator_key");
assert_eq!(
found[0].path,
"tasks[0].function.input.mappings[0].logic.shape"
);
assert!(
found[0].message.contains("`$shape`"),
"{}",
found[0].message
);
}
#[test]
fn a_nested_literal_is_reported_with_its_path() {
let found = constant(&mapping(json!({
"board": { "cells": [], "geometry": { "full": true } }
})));
assert_eq!(found.len(), 1, "{found:?}");
assert_eq!(
found[0].path,
"tasks[0].function.input.mappings[0].logic.board.geometry.full"
);
}
#[test]
fn a_working_constant_call_is_quiet() {
assert!(constant(&mapping(json!({ "zeros": [[2], "i64"] }))).is_empty());
assert!(
constant(&mapping(
json!({ "to_list": [{ "tensor": [[1, 2], "i64"] }] })
))
.is_empty()
);
}
#[test]
fn the_escape_and_multi_key_objects_are_quiet() {
assert!(constant(&mapping(json!({ "$shape": [6, 7] }))).is_empty());
assert!(constant(&mapping(json!({ "shape": "queue", "type": "channel" }))).is_empty());
assert!(dynamic(&mapping(json!({ "$shape": { "var": "data.dims" } }))).is_empty());
}
#[test]
fn a_dynamic_object_is_reported_only_in_the_dynamic_scope() {
let tasks = mapping(json!({ "shape": { "var": "data.dims" } }));
assert!(constant(&tasks).is_empty());
let found = dynamic(&tasks);
assert_eq!(found.len(), 1, "{found:?}");
assert_eq!(
found[0].path,
"tasks[0].function.input.mappings[0].logic.shape"
);
}
#[test]
fn arguments_of_a_call_are_not_walked() {
let found = dynamic(&mapping(json!({
"reshape": [{ "tensor": [{ "var": "data.cells" }, "i64"] }, [2, 2]]
})));
assert_eq!(found.len(), 1, "{found:?}");
assert!(found[0].path.ends_with(".reshape"));
}
#[test]
fn template_fields_are_walked_and_folded_fields_are_not() {
let tasks = json!([
{ "id": "c", "name": "C", "function": { "name": "http_call", "input": {
"connector": "api",
"path": { "cat": ["/v1/", { "var": "data.id" }] },
"body": { "shape": [6, 7] },
"headers": { "x-shape": { "shape": [6, 7] } }
} } },
{ "id": "w", "name": "W", "function": { "name": "cache_write", "input": {
"connector": "cache", "key": "k", "value": { "shape": [6, 7] }
} } }
]);
let found = constant(&tasks);
let mut paths: Vec<&str> = found.iter().map(|a| a.path.as_str()).collect();
paths.sort_unstable();
assert_eq!(
paths,
[
"tasks[0].function.input.body.shape",
"tasks[0].function.input.headers.x-shape.shape",
],
"{found:?}"
);
}
#[test]
fn conditions_are_not_walked() {
let tasks = json!([{ "id": "t", "name": "T",
"condition": { "==": [{ "var": "data.shape" }, { "shape": [1] }] },
"function": { "name": "log", "input": { "message": "x" } } }]);
assert!(constant(&tasks).is_empty());
assert!(dynamic(&tasks).is_empty());
}
}