use super::fingerprint::Fingerprint;
use crate::base::{AuthoredPath, Issue, IssueCode, PathSeg, StepKind};
use crate::def::{Logic, RejectionPolicy, StepDef, TaskDef, WorkflowDef};
use crate::expr::{ExprEngine, ExprVisitor, escaped_keys, secret_refs};
use crate::function::builtins::BuiltinKind;
use serde_json::Value;
use std::collections::hash_map::Entry;
use std::collections::{BTreeSet, HashMap};
pub(crate) type Stamp<'s> = Option<(StepKind, &'s str)>;
#[derive(Clone, Copy)]
pub(crate) struct Site<'s> {
pub(crate) at: &'s AuthoredPath,
pub(crate) records: bool,
pub(crate) step: Stamp<'s>,
pub(crate) field: Option<&'s str>,
}
impl Site<'_> {
fn stamp(&self, mut issue: Issue) -> Issue {
if let Some((kind, id)) = self.step {
issue = issue.with_step(kind, Some(id));
}
if let Some(field) = self.field {
issue = issue.with_field(field);
}
issue
}
}
pub(crate) trait Hook {
fn expr(&mut self, site: Site<'_>, raw: &Value, issues: &mut Vec<Issue>);
fn workflow(&mut self, _def: &WorkflowDef, _issues: &mut Vec<Issue>) {}
fn fingerprint(&self) -> Fingerprint {
Fingerprint::of(&BTreeSet::new(), &BTreeSet::new())
}
}
pub(crate) struct Checks<'e> {
exprs: &'e ExprEngine,
ops: BTreeSet<String>,
data: BTreeSet<String>,
}
impl<'e> Checks<'e> {
pub(crate) fn new(exprs: &'e ExprEngine) -> Self {
Self {
exprs,
ops: BTreeSet::new(),
data: BTreeSet::new(),
}
}
fn classify(&mut self, raw: &Value) {
match raw {
Value::Object(map) => {
if let Some(key) = map.keys().next().filter(|_| map.len() == 1) {
if self.exprs.operator(key).is_some() {
self.ops.insert(key.clone());
} else if !key.starts_with(self.exprs.escape()) {
self.data.insert(key.clone());
}
}
map.values().for_each(|child| self.classify(child));
}
Value::Array(items) => items.iter().for_each(|item| self.classify(item)),
_ => {}
}
}
fn secrets(&self, site: Site<'_>, raw: &Value, report: &mut Report<'_>) {
let mut refs = secret_refs(raw).peekable();
if refs.peek().is_none() {
return;
}
if site.records {
report(
IssueCode::SecretInMessageWrite,
site.at.clone(),
"reads a secret, and the engine records this expression's result; \
compute a derived value in a host function instead"
.to_owned(),
);
return;
}
for (name, _) in refs {
if let Some(name) = name
&& self.exprs.secrets().get(name).is_none()
{
report(
IssueCode::UnknownSecret,
site.at.clone(),
format!("secret `{name}` is not declared on the engine"),
);
}
}
}
fn template_keys(&self, at: &AuthoredPath, raw: &Value, report: &mut Report<'_>) {
let escape = self.exprs.escape();
for key_at in escaped_keys(raw) {
let Some(PathSeg::Key(key)) = key_at.segs().last() else {
continue;
};
let emitted = &key[escape.len_utf8()..];
let message = format!(
"`{key}` is emitted as `{emitted}`: one `{escape}` is stripped from every \
template key. Double it to `{escape}{key}` to emit `{key}` itself"
);
report(IssueCode::EscapedTemplateKey, at.extend(&key_at), message);
}
for (key_at, message) in duplicate_keys(raw, escape) {
report(IssueCode::DuplicateTemplateKey, at.extend(&key_at), message);
}
}
fn payload_vars(&self, at: &AuthoredPath, raw: &Value, report: &mut Report<'_>) {
if !matches!(raw, Value::Object(_) | Value::Array(_)) {
return;
}
let Ok(logic) = self.exprs.logic().compile(raw) else {
return;
};
for read in logic.facts().reads() {
if read.segments().first().map(String::as_str) != Some("payload") {
continue;
}
let path = read.to_string();
let message = format!(
"reads `{path}`, but the payload is not in the evaluation context, which \
holds only `data`, `metadata` and `temp_data`, so this always resolves to \
nothing; parse the payload into `data` first"
);
report(IssueCode::PayloadVar, at.clone(), message);
}
}
}
type Report<'r> = dyn FnMut(IssueCode, AuthoredPath, String) + 'r;
impl Hook for Checks<'_> {
fn expr(&mut self, site: Site<'_>, raw: &Value, issues: &mut Vec<Issue>) {
let mut report = |code, at, message: String| {
issues.push(site.stamp(Issue::new(code, at, message)));
};
self.secrets(site, raw, &mut report);
self.template_keys(site.at, raw, &mut report);
self.payload_vars(site.at, raw, &mut report);
self.classify(raw);
}
fn fingerprint(&self) -> Fingerprint {
Fingerprint::of(&self.ops, &self.data)
}
fn workflow(&mut self, def: &WorkflowDef, issues: &mut Vec<Issue>) {
unguarded_validation(self.exprs, def, issues);
}
}
fn duplicate_keys(raw: &Value, escape: char) -> Vec<(AuthoredPath, String)> {
let mut out = Vec::new();
let mut at = AuthoredPath::root();
walk_duplicates(raw, &mut at, escape, &mut out);
out
}
fn walk_duplicates(
value: &Value,
at: &mut AuthoredPath,
escape: char,
out: &mut Vec<(AuthoredPath, String)>,
) {
match value {
Value::Array(items) => {
for (i, item) in items.iter().enumerate() {
at.push(i);
walk_duplicates(item, at, escape, out);
at.pop();
}
}
Value::Object(map) => {
if map.len() > 1 {
let mut emitted: HashMap<&str, &str> = HashMap::with_capacity(map.len());
for key in map.keys() {
let name = key.strip_prefix(escape).unwrap_or(key);
match emitted.entry(name) {
Entry::Vacant(slot) => {
slot.insert(key);
}
Entry::Occupied(first) => {
let first = first.get();
let message = format!(
"`{first}` and `{key}` both emit the key `{name}`, so the object \
would carry it twice: a later read sees only the first, while \
serialization emits both"
);
out.push((at.join(key.as_str()), message));
}
}
}
}
for (key, child) in map {
at.push(key.as_str());
walk_duplicates(child, at, escape, out);
at.pop();
}
}
_ => {}
}
}
fn gates(exprs: &ExprEngine, condition: Option<&Logic>) -> bool {
condition.is_some_and(|c| {
exprs
.compile::<datavalue::OwnedDataValue>(&c.0)
.map_or(true, |expr| !expr.is_trivially_true())
})
}
fn unguarded_validation(exprs: &ExprEngine, def: &WorkflowDef, issues: &mut Vec<Issue>) {
struct Link<'d> {
path: AuthoredPath,
kind: StepKind,
task: &'d TaskDef,
guarded: bool,
}
let mut chain = Vec::new();
let mut opening = false;
for (path, kind, step) in def.walk() {
match step {
StepDef::Group(group) => opening |= gates(exprs, group.condition.as_ref()),
StepDef::Task(task) => {
let guarded = opening
|| gates(exprs, task.condition.as_ref())
|| BuiltinKind::from_name(&task.function.name) == Some(BuiltinKind::Filter);
chain.push(Link {
path,
kind,
task,
guarded,
});
opening = false;
}
}
}
for (i, link) in chain.iter().enumerate() {
let task = link.task;
if BuiltinKind::from_name(&task.function.name) != Some(BuiltinKind::Validation)
|| task.terminal
|| task.on_rejection == RejectionPolicy::Halt
{
continue;
}
let following = &chain[i + 1..];
let Some(next) = following.first() else {
continue;
};
if following.iter().any(|link| link.guarded) {
continue;
}
let message = format!(
"a failing rule here records status 400 and task `{}` still runs: \
`on_rejection` is `continue`. Add `\"on_rejection\": \"halt\"` to stop \
the workflow, or gate what follows on `metadata.progress.status_code`",
next.task.id
);
let issue = Issue::new(
IssueCode::UnguardedValidation,
link.path.join("on_rejection"),
message,
);
issues.push(issue.with_step(link.kind, Some(&task.id)));
}
}
pub(crate) struct Below<'a> {
pub(crate) hook: &'a mut dyn Hook,
pub(crate) issues: &'a mut Vec<Issue>,
pub(crate) root: &'a AuthoredPath,
pub(crate) step: Stamp<'a>,
}
impl ExprVisitor for Below<'_> {
fn expr(&mut self, at: &AuthoredPath, raw: &Value, records: bool) {
let field = match at.segs().first() {
Some(PathSeg::Key(key)) => Some(&**key),
_ => None,
};
let at = self.root.extend(at);
let site = Site {
at: &at,
records,
step: self.step,
field,
};
self.hook.expr(site, raw, self.issues);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::expr::Operator;
use serde_json::json;
fn duplicates(raw: &Value) -> Vec<String> {
duplicate_keys(raw, '$')
.into_iter()
.map(|(at, _)| at.to_string())
.collect()
}
#[test]
fn a_duplicate_is_the_later_key_emitting_a_name_already_emitted() {
assert_eq!(duplicates(&json!({"$a": 1, "a": 2})), ["a"]);
assert!(duplicates(&json!({"$$a": 1, "$a": 2, "b": 3})).is_empty());
assert_eq!(
duplicates(&json!({"x": {"$b": 1, "b": 2}, "y": [{"$$c": 1, "$c": 2}], "b": 3})),
["x.b"]
);
assert_eq!(
duplicates(&json!({"$k": [{"$a": 1, "a": 2}], "k": {"$z": 0, "z": 1}})),
["k", "$k[0].a", "k.z"]
);
}
struct Each;
impl datalogic_rs::CustomOperator for Each {
fn evaluate<'a>(
&self,
_args: &[&'a datalogic_rs::DataValue<'a>],
_ctx: &mut datalogic_rs::operator::EvalContext<'_, 'a>,
arena: &'a datalogic_rs::bumpalo::Bump,
) -> datalogic_rs::Result<&'a datalogic_rs::DataValue<'a>> {
Ok(arena.alloc(datalogic_rs::DataValue::Null))
}
}
fn payload_reads(raw: &Value) -> Vec<String> {
let exprs = ExprEngine::builder()
.all_operators()
.operator_with(Operator::custom("host_each").scoping(true), Each)
.build()
.unwrap();
let logic = exprs.logic().compile(raw).expect("the raw compiles");
logic
.facts()
.reads()
.iter()
.filter(|read| read.segments().first().map(String::as_str) == Some("payload"))
.map(ToString::to_string)
.collect()
}
#[test]
fn the_payload_reads_are_the_compiled_rules_root_reads() {
assert_eq!(
payload_reads(
&json!({"if": [{"var": ["payload.a", 1]}, {"val": ["payload", "b", 0]}, null]})
),
["payload.a", "payload.b.0"]
);
assert_eq!(
payload_reads(&json!({"var": ["data.a", {"var": "payload"}]})),
["payload"]
);
assert!(payload_reads(&json!({"var": "data.payload"})).is_empty());
assert!(payload_reads(&json!({"var": "payload", "x": 1})).is_empty());
}
#[test]
fn a_payload_read_inside_a_scope_is_not_a_root_read() {
assert_eq!(
payload_reads(&json!({"map": [{"val": "payload"}, {"var": "payload.x"}]})),
["payload"]
);
let p = || json!({"var": "payload"});
assert_eq!(
payload_reads(&json!({"host_each": [p(), p(), p()]})),
["payload"]
);
}
#[test]
fn a_payload_read_in_distincts_key_is_not_a_root_read() {
assert!(
payload_reads(&json!({"distinct": [{"var": "data.items"}, {"var": "payload.k"}]}))
.is_empty()
);
assert_eq!(
payload_reads(&json!({"distinct": [{"var": "payload.items"}, {"var": "payload.k"}]})),
["payload.items"]
);
}
#[test]
fn a_payload_read_in_trys_catch_arm_is_not_a_root_read() {
assert_eq!(
payload_reads(&json!({"try": [{"var": "payload.a"}, {"var": "payload.b"}]})),
["payload.a"]
);
}
#[test]
fn a_payload_read_through_missing_is_a_root_read() {
assert_eq!(
payload_reads(&json!({"missing": "payload.x"})),
["payload.x"]
);
assert_eq!(
payload_reads(&json!({"missing_some": [1, ["payload.x", "data.y"]]})),
["payload.x"]
);
}
#[test]
fn a_payload_read_through_exists_is_a_root_read() {
assert_eq!(payload_reads(&json!({"exists": "payload"})), ["payload"]);
assert_eq!(
payload_reads(&json!({"exists": ["payload", "x"]})),
["payload.x"]
);
assert!(payload_reads(&json!({"exists": "payload.x"})).is_empty());
assert_eq!(
payload_reads(&json!({"and": [{"var": "payload.a"}, {"exists": ["payload", "b"]}]})),
["payload.a", "payload.b"]
);
}
}