use std::collections::HashMap;
use serde::Deserialize;
use thiserror::Error;
use crate::pipeline::{FieldRule, Pipeline, ScanKind, Stage, TaintScope, TypeCheck};
use crate::plugin_decl::{PluginDeclaration, PluginOverride, PluginRegistry};
use crate::rules::{CompareOp, CompiledRoute, Condition, Effect, Expression, Literal, Rule};
use crate::step::{DelegateStep, PdpCall, PdpDialect, Step};
#[derive(Debug, Error)]
pub enum ParseError {
#[error("YAML parse error: {0}")]
Yaml(#[from] serde_yaml::Error),
#[error("rule '{rule}': {msg}")]
Rule { rule: String, msg: String },
#[error("unsupported step `{kind}` in rule '{rule}' โ defer to step 5b")]
UnsupportedStep { rule: String, kind: String },
#[error("predicate '{predicate}': {msg}")]
Predicate { predicate: String, msg: String },
}
#[derive(Debug, Clone, PartialEq)]
enum Tok {
Ident(String), StringLit(String),
IntLit(i64),
FloatLit(f64),
BoolLit(bool),
Eq, NotEq, Gt, GtEq, Lt, LtEq, And, Or, Not, LParen,
RParen,
Comma,
Contains, Require, Exists, In, }
struct Lexer<'a> {
src: &'a str,
bytes: &'a [u8],
pos: usize,
}
impl<'a> Lexer<'a> {
fn new(src: &'a str) -> Self {
Self {
src,
bytes: src.as_bytes(),
pos: 0,
}
}
fn peek(&self) -> Option<u8> {
self.bytes.get(self.pos).copied()
}
fn bump(&mut self) -> Option<u8> {
let b = self.peek()?;
self.pos += 1;
Some(b)
}
fn skip_ws(&mut self) {
while let Some(b) = self.peek() {
if b.is_ascii_whitespace() {
self.pos += 1;
} else {
break;
}
}
}
fn tokenize_all(&mut self) -> Result<Vec<Tok>, ParseError> {
let mut out = Vec::new();
loop {
self.skip_ws();
let Some(b) = self.peek() else {
return Ok(out);
};
let tok = match b {
b'(' => {
self.pos += 1;
Tok::LParen
},
b')' => {
self.pos += 1;
Tok::RParen
},
b',' => {
self.pos += 1;
Tok::Comma
},
b'&' => {
self.pos += 1;
Tok::And
},
b'|' => {
self.pos += 1;
Tok::Or
},
b'=' => {
self.pos += 1;
if self.peek() == Some(b'=') {
self.pos += 1;
Tok::Eq
} else {
return Err(self.err("expected `==`, saw `=`"));
}
},
b'!' => {
self.pos += 1;
if self.peek() == Some(b'=') {
self.pos += 1;
Tok::NotEq
} else {
Tok::Not
}
},
b'>' => {
self.pos += 1;
if self.peek() == Some(b'=') {
self.pos += 1;
Tok::GtEq
} else {
Tok::Gt
}
},
b'<' => {
self.pos += 1;
if self.peek() == Some(b'=') {
self.pos += 1;
Tok::LtEq
} else {
Tok::Lt
}
},
b'"' | b'\'' => self.lex_string(b)?,
b'-' | b'0'..=b'9' => self.lex_number()?,
b if is_ident_start(b) => self.lex_ident_or_keyword(),
_ => return Err(self.err(&format!("unexpected char `{}`", b as char))),
};
out.push(tok);
}
}
fn lex_string(&mut self, quote: u8) -> Result<Tok, ParseError> {
self.bump(); let start = self.pos;
while let Some(b) = self.peek() {
if b == quote {
break;
}
self.pos += 1;
}
if self.peek() != Some(quote) {
return Err(self.err("unterminated string literal"));
}
let s = std::str::from_utf8(&self.bytes[start..self.pos])
.map_err(|_| self.err("non-utf8 in string literal"))?
.to_string();
self.bump(); Ok(Tok::StringLit(s))
}
fn lex_number(&mut self) -> Result<Tok, ParseError> {
let start = self.pos;
if self.peek() == Some(b'-') {
self.pos += 1;
}
while let Some(b) = self.peek() {
if b.is_ascii_digit() {
self.pos += 1;
} else {
break;
}
}
let mut is_float = false;
if self.peek() == Some(b'.') {
is_float = true;
self.pos += 1;
while let Some(b) = self.peek() {
if b.is_ascii_digit() {
self.pos += 1;
} else {
break;
}
}
}
let text = &self.src[start..self.pos];
if is_float {
text.parse::<f64>()
.map(Tok::FloatLit)
.map_err(|_| self.err(&format!("bad float `{}`", text)))
} else {
text.parse::<i64>()
.map(Tok::IntLit)
.map_err(|_| self.err(&format!("bad int `{}`", text)))
}
}
fn lex_ident_or_keyword(&mut self) -> Tok {
let start = self.pos;
while let Some(b) = self.peek() {
if is_ident_cont(b) {
self.pos += 1;
} else {
break;
}
}
let s = &self.src[start..self.pos];
match s {
"true" => Tok::BoolLit(true),
"false" => Tok::BoolLit(false),
"contains" => Tok::Contains,
"require" => Tok::Require,
"exists" => Tok::Exists,
"in" => Tok::In,
_ => Tok::Ident(s.to_string()),
}
}
fn err(&self, msg: &str) -> ParseError {
ParseError::Predicate {
predicate: self.src.to_string(),
msg: format!("at byte {}: {}", self.pos, msg),
}
}
}
fn is_ident_start(b: u8) -> bool {
b.is_ascii_alphabetic() || b == b'_'
}
fn is_ident_cont(b: u8) -> bool {
b.is_ascii_alphanumeric() || b == b'_' || b == b'.'
}
struct PredParser<'a> {
src: &'a str,
toks: Vec<Tok>,
pos: usize,
}
impl<'a> PredParser<'a> {
fn parse(src: &'a str) -> Result<Expression, ParseError> {
let toks = Lexer::new(src).tokenize_all()?;
let mut p = Self { src, toks, pos: 0 };
let expr = p.parse_or()?;
if p.pos < p.toks.len() {
return Err(p.err(&format!(
"trailing tokens after expression: {:?}",
&p.toks[p.pos..]
)));
}
Ok(expr)
}
fn peek(&self) -> Option<&Tok> {
self.toks.get(self.pos)
}
fn bump(&mut self) -> Option<Tok> {
let t = self.toks.get(self.pos).cloned()?;
self.pos += 1;
Some(t)
}
fn err(&self, msg: &str) -> ParseError {
ParseError::Predicate {
predicate: self.src.to_string(),
msg: msg.to_string(),
}
}
fn parse_or(&mut self) -> Result<Expression, ParseError> {
let mut parts = vec![self.parse_and()?];
while matches!(self.peek(), Some(Tok::Or)) {
self.bump();
parts.push(self.parse_and()?);
}
Ok(if parts.len() == 1 {
parts.pop().unwrap()
} else {
Expression::Or(parts)
})
}
fn parse_and(&mut self) -> Result<Expression, ParseError> {
let mut parts = vec![self.parse_unary()?];
while matches!(self.peek(), Some(Tok::And)) {
self.bump();
parts.push(self.parse_unary()?);
}
Ok(if parts.len() == 1 {
parts.pop().unwrap()
} else {
Expression::And(parts)
})
}
fn parse_unary(&mut self) -> Result<Expression, ParseError> {
if matches!(self.peek(), Some(Tok::Not)) {
self.bump();
let inner = self.parse_unary()?;
return Ok(Expression::Not(Box::new(inner)));
}
self.parse_atom()
}
fn parse_atom(&mut self) -> Result<Expression, ParseError> {
match self.peek() {
Some(Tok::LParen) => {
self.bump();
let inner = self.parse_or()?;
match self.bump() {
Some(Tok::RParen) => Ok(inner),
_ => Err(self.err("expected `)`")),
}
},
Some(Tok::Require) => Err(self.err(
"`require(...)` is a rule-level shorthand, not a sub-predicate \
โ use `&` / `|` / `!` over bare identifiers instead",
)),
Some(Tok::Exists) => self.parse_exists(),
Some(Tok::Ident(_)) => self.parse_identifier_predicate(),
other => Err(self.err(&format!("expected atom, got {:?}", other))),
}
}
fn parse_exists(&mut self) -> Result<Expression, ParseError> {
self.bump(); match self.bump() {
Some(Tok::LParen) => {},
_ => return Err(self.err("expected `(` after `exists`")),
}
let key = match self.bump() {
Some(Tok::Ident(s)) => s,
other => {
return Err(self.err(&format!(
"exists(...) expects an attribute key, got {:?}",
other,
)))
},
};
match self.bump() {
Some(Tok::RParen) => {},
other => {
return Err(self.err(&format!(
"expected `)` after exists() argument, got {:?}",
other,
)))
},
}
Ok(Expression::Condition(Condition::Exists { key }))
}
fn parse_identifier_predicate(&mut self) -> Result<Expression, ParseError> {
let key = match self.bump() {
Some(Tok::Ident(s)) => s,
_ => unreachable!("parse_atom dispatched here"),
};
if matches!(self.peek(), Some(Tok::In)) {
self.bump();
return self.finish_in_set(key, false);
}
if let Some(Tok::Ident(maybe_not)) = self.peek() {
if maybe_not == "not" {
let saved_pos = self.pos;
self.bump(); if matches!(self.peek(), Some(Tok::In)) {
self.bump();
return self.finish_in_set(key, true);
}
self.pos = saved_pos;
}
}
let op = match self.peek() {
Some(Tok::Eq) => Some(CompareOp::Eq),
Some(Tok::NotEq) => Some(CompareOp::NotEq),
Some(Tok::Gt) => Some(CompareOp::Gt),
Some(Tok::GtEq) => Some(CompareOp::GtEq),
Some(Tok::Lt) => Some(CompareOp::Lt),
Some(Tok::LtEq) => Some(CompareOp::LtEq),
Some(Tok::Contains) => Some(CompareOp::Contains),
_ => None,
};
let Some(op) = op else {
return Ok(Expression::Condition(Condition::IsTrue { key }));
};
self.bump();
let value = match self.bump() {
Some(Tok::StringLit(s)) => Literal::String(s),
Some(Tok::IntLit(i)) => Literal::Int(i),
Some(Tok::FloatLit(f)) => Literal::Float(f),
Some(Tok::BoolLit(b)) => Literal::Bool(b),
Some(Tok::Ident(_)) => {
return Err(self.err(
"RHS-as-identifier on comparison operators not supported โ \
for set membership use `value_key in set_key`",
));
},
other => return Err(self.err(&format!("expected literal RHS, got {:?}", other))),
};
Ok(Expression::Condition(Condition::Comparison {
key,
op,
value,
}))
}
fn finish_in_set(&mut self, value_key: String, negate: bool) -> Result<Expression, ParseError> {
let set_key = match self.bump() {
Some(Tok::Ident(s)) => s,
other => {
return Err(self.err(&format!(
"expected set-attribute identifier after `{}in`, got {:?}",
if negate { "not " } else { "" },
other,
)))
},
};
Ok(Expression::Condition(Condition::InSet {
value_key,
set_key,
negate,
}))
}
}
pub fn parse_predicate(src: &str) -> Result<Expression, ParseError> {
PredParser::parse(src.trim())
}
pub fn parse_rule(line: &str, source: &str) -> Result<Rule, ParseError> {
let trimmed = line.trim();
if is_require_call(trimmed) {
let condition = parse_require_rule(trimmed)?;
return Ok(Rule::single(
condition,
Effect::Deny {
reason: None,
code: None,
},
source,
));
}
if let Some(kind) = detect_step_kind(trimmed) {
return Err(ParseError::UnsupportedStep {
rule: trimmed.to_string(),
kind: format!("{} (use parse_step for step kinds)", kind),
});
}
let (predicate_str, effects) = match split_predicate_action(trimmed) {
Some((p, a)) => (p, parse_action(a, trimmed)?),
None => {
if let Some(effects) = try_bare_action(trimmed) {
return Ok(Rule {
condition: Expression::Always,
effects,
source: source.to_string(),
});
}
if let Some(deny) = try_parse_deny_call(trimmed, trimmed)? {
return Ok(Rule {
condition: Expression::Always,
effects: vec![deny],
source: source.to_string(),
});
}
(
trimmed,
vec![Effect::Deny {
reason: None,
code: None,
}],
)
},
};
let condition = parse_predicate(predicate_str).map_err(|e| ParseError::Rule {
rule: trimmed.to_string(),
msg: format!("{}", e),
})?;
Ok(Rule {
condition,
effects,
source: source.to_string(),
})
}
fn is_require_call(s: &str) -> bool {
s.trim_start().starts_with("require(")
}
fn parse_require_rule(line: &str) -> Result<Expression, ParseError> {
let toks = Lexer::new(line).tokenize_all()?;
let mut iter = toks.into_iter().peekable();
let bad = |msg: &str| ParseError::Rule {
rule: line.to_string(),
msg: msg.to_string(),
};
match iter.next() {
Some(Tok::Require) => {},
_ => return Err(bad("expected `require`")),
}
match iter.next() {
Some(Tok::LParen) => {},
_ => return Err(bad("expected `(` after `require`")),
}
let mut keys = Vec::new();
let mut sep: Option<Tok> = None;
match iter.next() {
Some(Tok::Ident(s)) => keys.push(s),
_ => return Err(bad("expected identifier inside `require(...)`")),
}
loop {
match iter.next() {
Some(Tok::RParen) => break,
Some(t @ Tok::Comma) | Some(t @ Tok::Or) => {
match &sep {
None => sep = Some(t),
Some(prev) if std::mem::discriminant(prev) == std::mem::discriminant(&t) => {},
_ => {
return Err(bad(
"require(...) cannot mix `,` (AND) and `|` (OR) โ use one or the other",
))
},
}
match iter.next() {
Some(Tok::Ident(s)) => keys.push(s),
_ => return Err(bad("expected identifier after `,` or `|` in require(...)")),
}
},
Some(other) => {
return Err(bad(&format!(
"expected `,`, `|`, or `)` in require(...), got {:?}",
other,
)))
},
None => return Err(bad("unexpected end of require(...) โ missing `)`")),
}
}
if iter.peek().is_some() {
return Err(bad(
"trailing tokens after `require(...)` โ require is a complete rule",
));
}
let falses: Vec<Expression> = keys
.into_iter()
.map(|k| Expression::Condition(Condition::IsFalse { key: k }))
.collect();
if falses.len() == 1 {
return Ok(falses.into_iter().next().unwrap());
}
Ok(match sep {
Some(Tok::Or) => Expression::And(falses), _ => Expression::Or(falses), })
}
fn detect_step_kind(s: &str) -> Option<&'static str> {
let s = s.trim_start();
for prefix in [
"taint(",
"plugin(",
"run(",
"cedar:",
"opa(",
"authzen(",
"nemo(",
"cel:",
"sequential:",
"parallel:",
] {
if s.starts_with(prefix) {
return Some(prefix.trim_end_matches('(').trim_end_matches(':'));
}
}
None
}
fn split_predicate_action(s: &str) -> Option<(&str, &str)> {
let bytes = s.as_bytes();
let mut depth: i32 = 0;
let mut in_quote: Option<u8> = None;
let mut last_colon: Option<usize> = None;
for (i, &b) in bytes.iter().enumerate() {
match (in_quote, b) {
(Some(q), c) if c == q => in_quote = None,
(Some(_), _) => {},
(None, b'"') | (None, b'\'') => in_quote = Some(b),
(None, b'(') => depth += 1,
(None, b')') => depth -= 1,
(None, b':') if depth == 0 => last_colon = Some(i),
_ => {},
}
}
last_colon.map(|i| (s[..i].trim(), s[i + 1..].trim()))
}
fn parse_action(s: &str, rule: &str) -> Result<Vec<Effect>, ParseError> {
if let Some(effect) = try_bare_action(s) {
return Ok(effect);
}
if let Some(deny) = try_parse_deny_call(s.trim(), rule)? {
return Ok(vec![deny]);
}
Err(ParseError::Rule {
rule: rule.to_string(),
msg: format!(
"unsupported action `{}` โ recognized: `deny`, `deny('reason')`, `deny('reason', 'code')`, `allow`",
s.trim()
),
})
}
fn try_bare_action(s: &str) -> Option<Vec<Effect>> {
match s.trim() {
"deny" => Some(vec![Effect::Deny {
reason: None,
code: None,
}]),
"allow" => Some(vec![Effect::Allow]),
_ => None,
}
}
fn try_parse_deny_call(s: &str, rule: &str) -> Result<Option<Effect>, ParseError> {
if !s.starts_with("deny(") {
return Ok(None);
}
let inside = extract_call_args(s, "deny").ok_or_else(|| ParseError::Rule {
rule: rule.to_string(),
msg: "malformed `deny(...)`".into(),
})?;
let parts = split_top_level_commas(&inside).map_err(|e| ParseError::Rule {
rule: rule.to_string(),
msg: format!("deny(...): {}", e),
})?;
let mut iter = parts.into_iter();
let reason = match iter.next() {
Some(p) => Some(strip_string_literal(p.trim(), rule)?),
None => None,
};
let code = match iter.next() {
Some(p) => Some(strip_string_literal(p.trim(), rule)?),
None => None,
};
if iter.next().is_some() {
return Err(ParseError::Rule {
rule: rule.to_string(),
msg: "deny(...) takes at most two args: deny('reason', 'code')".into(),
});
}
Ok(Some(Effect::Deny { reason, code }))
}
fn strip_string_literal(s: &str, rule: &str) -> Result<String, ParseError> {
let s = s.trim();
if (s.starts_with('\'') && s.ends_with('\'') && s.len() >= 2)
|| (s.starts_with('"') && s.ends_with('"') && s.len() >= 2)
{
Ok(s[1..s.len() - 1].to_string())
} else {
Err(ParseError::Rule {
rule: rule.to_string(),
msg: format!("expected a quoted string, got `{}`", s),
})
}
}
pub fn parse_step(value: &serde_yaml::Value, source: &str) -> Result<Step, ParseError> {
match value {
serde_yaml::Value::String(s) => parse_step_string(s, source),
serde_yaml::Value::Mapping(m) => parse_step_map(m, source),
other => Err(ParseError::Rule {
rule: format!("{:?}", other),
msg: "step must be a string or a single-key map".into(),
}),
}
}
fn parse_step_string(line: &str, source: &str) -> Result<Step, ParseError> {
let trimmed = line.trim();
if trimmed.starts_with("taint(") {
let inside = extract_call_args(trimmed, "taint").ok_or_else(|| ParseError::Rule {
rule: trimmed.to_string(),
msg: "malformed `taint(...)`".into(),
})?;
let taint_stage = parse_taint(&inside, trimmed)?;
if let Stage::Taint { label, scopes } = taint_stage {
return Ok(Step::Taint { label, scopes });
}
unreachable!("parse_taint always returns Stage::Taint");
}
let plugin_verb = if trimmed.starts_with("plugin(") {
Some("plugin")
} else if trimmed.starts_with("run(") {
Some("run")
} else {
None
};
if let Some(verb) = plugin_verb {
let inside = extract_call_args(trimmed, verb).ok_or_else(|| ParseError::Rule {
rule: trimmed.to_string(),
msg: format!("malformed `{verb}(...)`"),
})?;
let name = inside.trim();
if name.is_empty() {
return Err(ParseError::Rule {
rule: trimmed.to_string(),
msg: format!("`{verb}(...)`: plugin name must not be empty"),
});
}
return Ok(Step::Plugin {
name: name.to_string(),
});
}
if trimmed.starts_with("delegate(") {
let inside = extract_call_args(trimmed, "delegate").ok_or_else(|| ParseError::Rule {
rule: trimmed.to_string(),
msg: "malformed `delegate(...)`".into(),
})?;
let parsed = parse_delegate_call_args(&inside, source)?;
return Ok(Step::Delegate(DelegateStep {
plugin_name: parsed.plugin_name,
config_override: parsed.config_override,
on_error: parsed.on_error,
source: source.to_string(),
}));
}
let rule = parse_rule(trimmed, source)?;
Ok(Step::Rule(rule))
}
struct ParsedDelegateCall {
plugin_name: String,
config_override: Option<serde_yaml::Value>,
on_error: Option<String>,
}
fn parse_delegate_call_args(inside: &str, source: &str) -> Result<ParsedDelegateCall, ParseError> {
let parts = split_top_level_commas(inside).map_err(|msg| ParseError::Rule {
rule: format!("delegate({inside})"),
msg: format!("{source}: {msg}"),
})?;
let mut parts_iter = parts.into_iter();
let plugin_name = parts_iter
.next()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.ok_or_else(|| ParseError::Rule {
rule: format!("delegate({inside})"),
msg: format!(
"{source}: `delegate(...)` requires a plugin name as the first \
positional argument"
),
})?;
let plugin_name = strip_wrapping_quotes(&plugin_name).to_string();
if plugin_name.is_empty() {
return Err(ParseError::Rule {
rule: format!("delegate({inside})"),
msg: format!("{source}: `delegate(...)` plugin name cannot be empty"),
});
}
let mut on_error: Option<String> = None;
let mut config_map = serde_yaml::Mapping::new();
for raw_kwarg in parts_iter {
let kwarg = raw_kwarg.trim();
if kwarg.is_empty() {
continue;
}
let (key, value_str) = kwarg.split_once(':').ok_or_else(|| ParseError::Rule {
rule: kwarg.to_string(),
msg: format!(
"{source}: `delegate(...)` kwarg `{kwarg}` must be `key: value` \
(use the map form for richer config)"
),
})?;
let key = key.trim();
let value_str = value_str.trim();
if key.is_empty() {
return Err(ParseError::Rule {
rule: kwarg.to_string(),
msg: format!("{source}: `delegate(...)` kwarg has empty key"),
});
}
if key == "on_error" {
let val = parse_delegate_value(value_str).map_err(|msg| ParseError::Rule {
rule: kwarg.to_string(),
msg: format!("{source}: on_error: {msg}"),
})?;
on_error = Some(
val.as_str()
.ok_or_else(|| ParseError::Rule {
rule: kwarg.to_string(),
msg: format!("{source}: `on_error` must be a string"),
})?
.to_string(),
);
continue;
}
if key == "plugin" {
return Err(ParseError::Rule {
rule: kwarg.to_string(),
msg: format!(
"{source}: `plugin` is set as the first positional argument \
of `delegate(...)`; don't pass it as a kwarg too"
),
});
}
let value = parse_delegate_value(value_str).map_err(|msg| ParseError::Rule {
rule: kwarg.to_string(),
msg: format!("{source}: `{key}`: {msg}"),
})?;
config_map.insert(serde_yaml::Value::String(key.to_string()), value);
}
let config_override = if config_map.is_empty() {
None
} else {
Some(serde_yaml::Value::Mapping(config_map))
};
Ok(ParsedDelegateCall {
plugin_name,
config_override,
on_error,
})
}
fn split_top_level_commas(input: &str) -> Result<Vec<String>, String> {
let mut parts = Vec::new();
let mut current = String::new();
let mut bracket_depth: usize = 0;
let mut quote: Option<char> = None;
let mut escape = false;
for ch in input.chars() {
if escape {
current.push(ch);
escape = false;
continue;
}
if let Some(q) = quote {
current.push(ch);
if ch == '\\' {
escape = true;
} else if ch == q {
quote = None;
}
continue;
}
match ch {
'"' | '\'' => {
quote = Some(ch);
current.push(ch);
},
'[' | '(' | '{' => {
bracket_depth += 1;
current.push(ch);
},
']' | ')' | '}' => {
bracket_depth = bracket_depth
.checked_sub(1)
.ok_or_else(|| format!("unmatched `{ch}` in delegate(...) args"))?;
current.push(ch);
},
',' if bracket_depth == 0 => {
parts.push(std::mem::take(&mut current));
},
_ => current.push(ch),
}
}
if quote.is_some() {
return Err("unterminated quoted string in delegate(...) args".to_string());
}
if bracket_depth != 0 {
return Err("unbalanced brackets in delegate(...) args".to_string());
}
parts.push(current);
Ok(parts)
}
fn parse_delegate_value(s: &str) -> Result<serde_yaml::Value, String> {
let trimmed = s.trim();
if trimmed.is_empty() {
return Err("empty value".to_string());
}
if let Some(stripped) = trimmed.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
let items = split_top_level_commas(stripped)?;
let mut out = Vec::with_capacity(items.len());
for item in items {
let item = item.trim();
if item.is_empty() {
continue;
}
out.push(parse_delegate_value(item)?);
}
return Ok(serde_yaml::Value::Sequence(out));
}
if (trimmed.starts_with('"') && trimmed.ends_with('"') && trimmed.len() >= 2)
|| (trimmed.starts_with('\'') && trimmed.ends_with('\'') && trimmed.len() >= 2)
{
return Ok(serde_yaml::Value::String(
trimmed[1..trimmed.len() - 1].to_string(),
));
}
if trimmed == "true" {
return Ok(serde_yaml::Value::Bool(true));
}
if trimmed == "false" {
return Ok(serde_yaml::Value::Bool(false));
}
if let Ok(n) = trimmed.parse::<i64>() {
return Ok(serde_yaml::Value::Number(serde_yaml::Number::from(n)));
}
if let Ok(f) = trimmed.parse::<f64>() {
return Ok(serde_yaml::Value::Number(serde_yaml::Number::from(f)));
}
Ok(serde_yaml::Value::String(trimmed.to_string()))
}
fn strip_wrapping_quotes(s: &str) -> &str {
let bytes = s.as_bytes();
if bytes.len() >= 2 {
let first = bytes[0];
let last = bytes[bytes.len() - 1];
if (first == b'"' && last == b'"') || (first == b'\'' && last == b'\'') {
return &s[1..s.len() - 1];
}
}
s
}
fn parse_step_map(m: &serde_yaml::Mapping, source: &str) -> Result<Step, ParseError> {
if has_key(m, "when") && has_key(m, "do") {
return parse_when_do_rule(m, source);
}
if m.len() != 1 {
return Err(ParseError::Rule {
rule: format!("{:?}", m),
msg: "step map must have exactly one key (PDP call signature, \
`when:`/`do:`, or a `predicate: [effects...]` shorthand)"
.into(),
});
}
let (key_val, body_val) = m.iter().next().unwrap();
let key = key_val.as_str().ok_or_else(|| ParseError::Rule {
rule: format!("{:?}", key_val),
msg: "PDP step key must be a string".into(),
})?;
if let serde_yaml::Value::Sequence(items) = body_val {
let trimmed = key.trim();
if trimmed != "delegate"
&& trimmed != "sequential"
&& trimmed != "parallel"
&& !is_known_pdp_dialect(trimmed)
{
return parse_shorthand_multi_effect(trimmed, items, source);
}
}
if key.trim() == "delegate" {
return parse_delegate_step(body_val, source);
}
match key.trim() {
"sequential" => {
let effect = parse_sequential_effect(body_val, source)?;
return Ok(Step::Rule(Rule {
condition: Expression::Always,
effects: vec![effect],
source: source.to_string(),
}));
},
"parallel" => {
let effect = parse_parallel_effect(body_val, source)?;
return Ok(Step::Rule(Rule {
condition: Expression::Always,
effects: vec![effect],
source: source.to_string(),
}));
},
_ => {},
}
let (dialect_str, paren_args) = if let Some(open) = key.find('(') {
let close = key.rfind(')').ok_or_else(|| ParseError::Rule {
rule: key.to_string(),
msg: "missing `)` in PDP call signature".into(),
})?;
let inside = key[open + 1..close].trim().to_string();
(key[..open].trim(), Some(inside))
} else {
(key.trim(), None)
};
let dialect = PdpDialect::from_key(dialect_str);
let body = body_val.as_mapping().ok_or_else(|| ParseError::Rule {
rule: format!("{:?}", body_val),
msg: format!(
"`{}:` body must be a map (with on_deny / on_allow / args)",
key
),
})?;
let (args, on_deny, on_allow) = extract_pdp_body(body, paren_args.as_deref(), source)?;
Ok(Step::Pdp {
call: PdpCall { dialect, args },
on_deny,
on_allow,
})
}
fn has_key(m: &serde_yaml::Mapping, key: &str) -> bool {
m.contains_key(serde_yaml::Value::String(key.to_string()))
}
fn is_known_pdp_dialect(key: &str) -> bool {
let base = key.find('(').map(|i| &key[..i]).unwrap_or(key);
matches!(base.trim(), "cedar" | "opa" | "authzen" | "nemo" | "cel")
}
fn parse_when_do_rule(m: &serde_yaml::Mapping, source: &str) -> Result<Step, ParseError> {
for (k, _) in m.iter() {
let key = k.as_str().unwrap_or("");
if !matches!(key, "when" | "do" | "id") {
return Err(ParseError::Rule {
rule: format!("{:?}", m),
msg: format!(
"unexpected key `{}` in when/do rule (allowed: `when`, `do`, `id`)",
key
),
});
}
}
let when_val = m
.get(serde_yaml::Value::String("when".into()))
.expect("has_key verified above");
let predicate = when_val.as_str().ok_or_else(|| ParseError::Rule {
rule: format!("{:?}", when_val),
msg: "`when:` must be a predicate string".into(),
})?;
let condition = parse_predicate(predicate).map_err(|e| ParseError::Rule {
rule: format!("when: {}", predicate),
msg: format!("{}", e),
})?;
let do_val = m
.get(serde_yaml::Value::String("do".into()))
.expect("has_key verified above");
let effects = parse_do_body(do_val, source)?;
if effects.is_empty() {
return Err(ParseError::Rule {
rule: format!("{:?}", m),
msg: "`do:` produced no effects".into(),
});
}
Ok(Step::Rule(Rule {
condition,
effects,
source: source.to_string(),
}))
}
fn parse_shorthand_multi_effect(
predicate: &str,
effect_list: &[serde_yaml::Value],
source: &str,
) -> Result<Step, ParseError> {
let condition = parse_predicate(predicate).map_err(|e| ParseError::Rule {
rule: predicate.to_string(),
msg: format!("{}", e),
})?;
let mut effects = Vec::with_capacity(effect_list.len());
for item in effect_list {
effects.push(parse_effect_value(item, source)?);
}
if effects.is_empty() {
return Err(ParseError::Rule {
rule: predicate.to_string(),
msg: "shorthand multi-effect map produced no effects".into(),
});
}
Ok(Step::Rule(Rule {
condition,
effects,
source: source.to_string(),
}))
}
fn parse_do_body(val: &serde_yaml::Value, source: &str) -> Result<Vec<Effect>, ParseError> {
match val {
serde_yaml::Value::String(s) => Ok(vec![parse_effect_string(s, source)?]),
serde_yaml::Value::Sequence(items) => items
.iter()
.map(|item| parse_effect_value(item, source))
.collect(),
serde_yaml::Value::Mapping(_) => {
Ok(vec![parse_effect_value(val, source)?])
},
other => Err(ParseError::Rule {
rule: format!("{:?}", other),
msg: "`do:` value must be a string, a list of effects, or an effect map".into(),
}),
}
}
fn parse_effect_value(val: &serde_yaml::Value, source: &str) -> Result<Effect, ParseError> {
match val {
serde_yaml::Value::String(s) => parse_effect_string(s, source),
serde_yaml::Value::Mapping(m) => {
if m.len() == 1 {
let (k, v) = m.iter().next().unwrap();
if let Some(key_str) = k.as_str() {
match key_str.trim() {
"sequential" => return parse_sequential_effect(v, source),
"parallel" => return parse_parallel_effect(v, source),
_ => {},
}
}
}
let step = parse_step(val, source)?;
step_to_effect(step, source)
},
other => Err(ParseError::Rule {
rule: format!("{:?}", other),
msg: "effect entry must be a string or a map".into(),
}),
}
}
fn parse_sequential_effect(body: &serde_yaml::Value, source: &str) -> Result<Effect, ParseError> {
let items = body.as_sequence().ok_or_else(|| ParseError::Rule {
rule: format!("{:?}", body),
msg: "`sequential:` body must be a list of effects".into(),
})?;
if items.is_empty() {
return Err(ParseError::Rule {
rule: format!("{:?}", body),
msg: "`sequential:` body is empty".into(),
});
}
let mut effects = Vec::with_capacity(items.len());
for item in items {
effects.push(parse_effect_value(item, source)?);
}
Ok(Effect::Sequential(effects))
}
fn parse_parallel_effect(body: &serde_yaml::Value, source: &str) -> Result<Effect, ParseError> {
let items = body.as_sequence().ok_or_else(|| ParseError::Rule {
rule: format!("{:?}", body),
msg: "`parallel:` body must be a list of effects".into(),
})?;
if items.is_empty() {
return Err(ParseError::Rule {
rule: format!("{:?}", body),
msg: "`parallel:` body is empty".into(),
});
}
let mut effects = Vec::with_capacity(items.len());
for item in items {
effects.push(parse_effect_value(item, source)?);
}
let parallel = Effect::Parallel(effects);
parallel
.validate_parallel_purity()
.map_err(|msg| ParseError::Rule {
rule: source.to_string(),
msg,
})?;
Ok(parallel)
}
fn parse_effect_string(s: &str, source: &str) -> Result<Effect, ParseError> {
let trimmed = s.trim();
if let Some(mut effects) = try_bare_action(trimmed) {
if effects.len() == 1 {
return Ok(effects.pop().unwrap());
}
}
if let Some(effect) = try_parse_deny_call(trimmed, s)? {
return Ok(effect);
}
if let Some(field_op) = try_parse_field_op(trimmed, s)? {
return Ok(field_op);
}
let step = parse_step_string(s, source)?;
step_to_effect(step, source)
}
fn try_parse_field_op(s: &str, rule: &str) -> Result<Option<Effect>, ParseError> {
let Some(pipe_idx) = find_top_level_pipe(s) else {
return Ok(None);
};
let path = s[..pipe_idx].trim();
let chain = s[pipe_idx + 1..].trim();
if path.is_empty() || chain.is_empty() {
return Ok(None);
}
if !is_valid_field_path(path) {
return Ok(None);
}
let pipeline = parse_pipeline(chain).map_err(|e| ParseError::Rule {
rule: rule.to_string(),
msg: format!("field op `{}`: {}", path, e),
})?;
if pipeline.stages.is_empty() {
return Err(ParseError::Rule {
rule: rule.to_string(),
msg: format!("field op `{}` has no stages", path),
});
}
Ok(Some(Effect::FieldOp {
path: path.to_string(),
stages: pipeline.stages,
}))
}
fn find_top_level_pipe(s: &str) -> Option<usize> {
let bytes = s.as_bytes();
let mut depth: i32 = 0;
let mut quote: Option<u8> = None;
let mut i = 0;
while i < bytes.len() {
let b = bytes[i];
if let Some(q) = quote {
if b == b'\\' {
i += 2;
continue;
}
if b == q {
quote = None;
}
i += 1;
continue;
}
match b {
b'\'' | b'"' => quote = Some(b),
b'(' | b'[' => depth += 1,
b')' | b']' => depth -= 1,
b'|' if depth == 0 => {
if bytes.get(i + 1) == Some(&b'|') {
i += 2;
continue;
}
return Some(i);
},
_ => {},
}
i += 1;
}
None
}
fn is_valid_field_path(s: &str) -> bool {
let Some(rest) = s
.strip_prefix("args.")
.or_else(|| s.strip_prefix("result."))
else {
return false;
};
!rest.is_empty()
&& rest
.split('.')
.all(|seg| !seg.is_empty() && seg.chars().all(|c| c.is_alphanumeric() || c == '_'))
}
pub(crate) fn step_to_top_level_effect(step: Step) -> Result<Effect, ParseError> {
match step {
Step::Rule(rule) => Ok(Effect::When {
condition: rule.condition,
body: rule.effects,
source: rule.source,
}),
Step::Pdp {
call,
on_allow,
on_deny,
} => {
let on_allow = on_allow
.into_iter()
.map(step_to_top_level_effect)
.collect::<Result<Vec<_>, _>>()?;
let on_deny = on_deny
.into_iter()
.map(step_to_top_level_effect)
.collect::<Result<Vec<_>, _>>()?;
Ok(Effect::Pdp {
call,
on_allow,
on_deny,
})
},
Step::Plugin { name } => Ok(Effect::Plugin { name }),
Step::Delegate(d) => Ok(Effect::Delegate(d)),
Step::Taint { label, scopes } => Ok(Effect::Taint { label, scopes }),
}
}
fn step_to_effect(step: Step, source: &str) -> Result<Effect, ParseError> {
match step {
Step::Plugin { name } => Ok(Effect::Plugin { name }),
Step::Delegate(d) => Ok(Effect::Delegate(d)),
Step::Taint { label, scopes } => Ok(Effect::Taint { label, scopes }),
Step::Rule(rule) => {
if !matches!(rule.condition, Expression::Always) {
return Err(ParseError::Rule {
rule: source.to_string(),
msg: "conditional rules nested inside `do:` are not supported in E1 \
(use a sibling `when:`/`do:` rule instead)"
.into(),
});
}
if rule.effects.len() != 1 {
return Err(ParseError::Rule {
rule: source.to_string(),
msg: format!(
"unconditional rule inside `do:` must produce exactly one \
effect, got {}",
rule.effects.len()
),
});
}
Ok(rule.effects.into_iter().next().unwrap())
},
Step::Pdp { .. } => Err(ParseError::Rule {
rule: source.to_string(),
msg: "PDP calls inside `do:` are not supported in E1 (use a sibling \
step instead)"
.into(),
}),
}
}
fn parse_delegate_step(body_val: &serde_yaml::Value, source: &str) -> Result<Step, ParseError> {
let body = body_val.as_mapping().ok_or_else(|| ParseError::Rule {
rule: source.to_string(),
msg: "`delegate:` body must be a map with `plugin:` and optional \
`config:` / `on_error:`"
.to_string(),
})?;
let plugin = body
.get(serde_yaml::Value::String("plugin".to_string()))
.ok_or_else(|| ParseError::Rule {
rule: source.to_string(),
msg: "`delegate:` requires `plugin: <name>` referencing a \
top-level plugin registered under `token.delegate`"
.to_string(),
})?;
let plugin_name = plugin
.as_str()
.ok_or_else(|| ParseError::Rule {
rule: source.to_string(),
msg: "`delegate.plugin` must be a string".to_string(),
})?
.to_string();
if plugin_name.is_empty() {
return Err(ParseError::Rule {
rule: source.to_string(),
msg: "`delegate.plugin` cannot be empty".to_string(),
});
}
let config_override = body
.get(serde_yaml::Value::String("config".to_string()))
.cloned();
let on_error = match body.get(serde_yaml::Value::String("on_error".to_string())) {
Some(v) => Some(
v.as_str()
.ok_or_else(|| ParseError::Rule {
rule: source.to_string(),
msg: "`delegate.on_error` must be a string (e.g. `deny`, \
`continue`)"
.to_string(),
})?
.to_string(),
),
None => None,
};
Ok(Step::Delegate(DelegateStep {
plugin_name,
config_override,
on_error,
source: source.to_string(),
}))
}
fn extract_pdp_body(
body: &serde_yaml::Mapping,
paren_args: Option<&str>,
source: &str,
) -> Result<(serde_yaml::Value, Vec<Step>, Vec<Step>), ParseError> {
let mut on_deny = Vec::new();
let mut on_allow = Vec::new();
let mut args_map = serde_yaml::Mapping::new();
for (k, v) in body {
match k.as_str() {
Some("on_deny") => {
on_deny = parse_reaction_list(v, source, "on_deny")?;
},
Some("on_allow") => {
on_allow = parse_reaction_list(v, source, "on_allow")?;
},
_ => {
args_map.insert(k.clone(), v.clone());
},
}
}
let args = match paren_args {
Some(s) => serde_yaml::Value::String(s.to_string()),
None => serde_yaml::Value::Mapping(args_map),
};
Ok((args, on_deny, on_allow))
}
fn parse_reaction_list(
v: &serde_yaml::Value,
source: &str,
which: &str,
) -> Result<Vec<Step>, ParseError> {
let list = v.as_sequence().ok_or_else(|| ParseError::Rule {
rule: format!("{:?}", v),
msg: format!("`{}:` must be a list of steps", which),
})?;
list.iter()
.enumerate()
.map(|(i, entry)| parse_step(entry, &format!("{}.{}[{}]", source, which, i)))
.collect()
}
fn extract_call_args(line: &str, name: &str) -> Option<String> {
let line = line.trim();
if !line.starts_with(name) {
return None;
}
let after = &line[name.len()..];
if !after.starts_with('(') {
return None;
}
let bytes = after.as_bytes();
let mut depth = 0;
for (i, &b) in bytes.iter().enumerate() {
match b {
b'(' => depth += 1,
b')' => {
depth -= 1;
if depth == 0 {
if after[i + 1..].trim().is_empty() {
return Some(after[1..i].to_string());
}
return None;
}
},
_ => {},
}
}
None
}
pub fn parse_pipeline(src: &str) -> Result<Pipeline, ParseError> {
let mut pipeline = Pipeline::new();
for seg in split_top_level(src.trim(), b'|') {
let seg = seg.trim();
if seg.is_empty() {
continue;
}
pipeline.push(parse_stage(seg)?);
}
Ok(pipeline)
}
fn split_top_level(s: &str, delim: u8) -> Vec<&str> {
let bytes = s.as_bytes();
let mut out = Vec::new();
let mut depth: i32 = 0;
let mut in_quote: Option<u8> = None;
let mut start = 0;
for (i, &b) in bytes.iter().enumerate() {
match (in_quote, b) {
(Some(q), c) if c == q => in_quote = None,
(Some(_), _) => {},
(None, b'"') | (None, b'\'') => in_quote = Some(b),
(None, b'(') | (None, b'[') => depth += 1,
(None, b')') | (None, b']') => depth -= 1,
(None, c) if c == delim && depth == 0 => {
out.push(&s[start..i]);
start = i + 1;
},
_ => {},
}
}
out.push(&s[start..]);
out
}
fn parse_stage(src: &str) -> Result<Stage, ParseError> {
let s = src.trim();
let bad = |msg: &str| ParseError::Predicate {
predicate: src.to_string(),
msg: msg.to_string(),
};
if let Some(stage) = try_parse_range(s) {
return Ok(stage);
}
let (head, args) = split_head_args(s).ok_or_else(|| bad("expected stage identifier"))?;
match (head, args.as_deref()) {
("str", None) => Ok(Stage::Type(TypeCheck::Str)),
("int", None) => Ok(Stage::Type(TypeCheck::Int)),
("bool", None) => Ok(Stage::Type(TypeCheck::Bool)),
("float", None) => Ok(Stage::Type(TypeCheck::Float)),
("email", None) => Ok(Stage::Type(TypeCheck::Email)),
("url", None) => Ok(Stage::Type(TypeCheck::Url)),
("uuid", None) => Ok(Stage::Type(TypeCheck::Uuid)),
("redact", None) => Ok(Stage::Redact { condition: None }),
("omit", None) => Ok(Stage::Omit),
("hash", None) => Ok(Stage::Hash),
("pii.redact", None) => Ok(Stage::Scan {
kind: ScanKind::PiiRedact,
}),
("pii.detect", None) => Ok(Stage::Scan {
kind: ScanKind::PiiDetect,
}),
("injection.scan", None) => Ok(Stage::Scan {
kind: ScanKind::InjectionScan,
}),
("mask", Some(a)) => {
let n: usize = a
.trim()
.parse()
.map_err(|_| bad(&format!("mask(N) expects integer, got `{}`", a)))?;
Ok(Stage::Mask { keep_last: n })
},
("redact", Some(a)) => {
let cond = parse_predicate(a).map_err(|e| ParseError::Predicate {
predicate: src.to_string(),
msg: format!("invalid redact() condition: {}", e),
})?;
Ok(Stage::Redact {
condition: Some(cond),
})
},
("hash", Some(_)) => Err(bad("hash takes no arguments")),
("omit", Some(_)) => Err(bad(
"omit takes no arguments โ for conditional omit, use a policy rule predicate",
)),
("len", Some(a)) => {
let (min, max) = parse_range_inner(a)
.ok_or_else(|| bad(&format!("len(...) expects N..M range, got `{}`", a)))?;
let to_usize = |v: i64| -> Result<usize, ParseError> {
if v < 0 {
Err(bad("len bounds must be non-negative"))
} else {
Ok(v as usize)
}
};
Ok(Stage::Length {
min: min.map(to_usize).transpose()?,
max: max.map(to_usize).transpose()?,
})
},
("enum", Some(a)) => {
let values = split_top_level(a, b',')
.into_iter()
.map(|v| {
let t = v.trim();
if (t.starts_with('"') && t.ends_with('"'))
|| (t.starts_with('\'') && t.ends_with('\''))
{
t[1..t.len() - 1].to_string()
} else {
t.to_string()
}
})
.filter(|s| !s.is_empty())
.collect::<Vec<_>>();
if values.is_empty() {
return Err(bad("enum() requires at least one value"));
}
Ok(Stage::Enum { values })
},
("regex", Some(a)) => {
let pattern = a.trim();
let pat = if (pattern.starts_with('"') && pattern.ends_with('"'))
|| (pattern.starts_with('\'') && pattern.ends_with('\''))
{
pattern[1..pattern.len() - 1].to_string()
} else {
pattern.to_string()
};
Ok(Stage::Regex { pattern: pat })
},
("validate", Some(a)) => {
Err(bad(&format!(
"`validate({})` โ named-validator dispatch is not implemented \
in this build. Use `regex(\"pattern\")` for a named-regex \
equivalent, or `plugin({})` for richer validation logic.",
a.trim(),
a.trim(),
)))
},
("plugin" | "run", Some(a)) => {
let name = a.trim();
if name.is_empty() {
return Err(bad(&format!(
"`{head}(...)`: plugin name must not be empty"
)));
}
Ok(Stage::Plugin {
name: name.to_string(),
})
},
("taint", Some(a)) => parse_taint(a, src),
(other, _) => Err(bad(&format!("unknown stage `{}`", other))),
}
}
fn try_parse_range(s: &str) -> Option<Stage> {
if !s.contains("..") {
return None;
}
let first = s.as_bytes().first().copied()?;
if first.is_ascii_alphabetic() || first == b'_' {
return None;
}
let (min, max) = parse_range_inner(s)?;
Some(Stage::Range { min, max })
}
fn parse_range_inner(s: &str) -> Option<(Option<i64>, Option<i64>)> {
let dotdot = s.find("..")?;
let left = s[..dotdot].trim();
let right = s[dotdot + 2..].trim();
let min = if left.is_empty() {
None
} else {
Some(parse_numeric_with_suffix(left)?)
};
let max = if right.is_empty() {
None
} else {
Some(parse_numeric_with_suffix(right)?)
};
if min.is_none() && max.is_none() {
return None; }
Some((min, max))
}
fn parse_numeric_with_suffix(s: &str) -> Option<i64> {
let s = s.trim();
if s.is_empty() {
return None;
}
let (num_part, mult) = match s.as_bytes().last().copied()? {
b'k' | b'K' => (&s[..s.len() - 1], 1_000_i64),
b'm' | b'M' => (&s[..s.len() - 1], 1_000_000_i64),
_ => (s, 1_i64),
};
let n: i64 = num_part.parse().ok()?;
n.checked_mul(mult)
}
fn split_head_args(s: &str) -> Option<(&str, Option<String>)> {
if let Some(open) = s.find('(') {
let bytes = s.as_bytes();
let mut depth = 0;
let mut close = None;
for (i, &b) in bytes.iter().enumerate().skip(open) {
match b {
b'(' => depth += 1,
b')' => {
depth -= 1;
if depth == 0 {
close = Some(i);
break;
}
},
_ => {},
}
}
let close = close?;
let head = s[..open].trim();
if head.is_empty() {
return None;
}
let args = s[open + 1..close].to_string();
if s[close + 1..].trim().is_empty() {
Some((head, Some(args)))
} else {
None
}
} else {
let head = s.trim();
if head.is_empty() {
None
} else {
Some((head, None))
}
}
}
fn parse_taint(args: &str, src: &str) -> Result<Stage, ParseError> {
let parts = split_top_level(args, b',');
if parts.is_empty() {
return Err(ParseError::Predicate {
predicate: src.to_string(),
msg: "taint() requires at least a label".into(),
});
}
let label = parts[0].trim().to_string();
if label.is_empty() {
return Err(ParseError::Predicate {
predicate: src.to_string(),
msg: "taint label must not be empty".into(),
});
}
let scopes = if parts.len() == 1 {
vec![TaintScope::Session] } else {
let scope_arg = parts[1..].join(",");
let scope_arg = scope_arg.trim();
if scope_arg.starts_with('[') && scope_arg.ends_with(']') {
split_top_level(&scope_arg[1..scope_arg.len() - 1], b',')
.into_iter()
.map(|s| parse_taint_scope(s.trim(), src))
.collect::<Result<Vec<_>, _>>()?
} else {
vec![parse_taint_scope(scope_arg, src)?]
}
};
Ok(Stage::Taint { label, scopes })
}
fn parse_taint_scope(s: &str, src: &str) -> Result<TaintScope, ParseError> {
match s {
"session" => Ok(TaintScope::Session),
"message" => Ok(TaintScope::Message),
other => Err(ParseError::Predicate {
predicate: src.to_string(),
msg: format!(
"unknown taint scope `{}` (expected `session` or `message`)",
other
),
}),
}
}
#[derive(Debug, Default, Deserialize)]
pub struct ConfigYaml {
#[serde(default)]
pub routes: HashMap<String, RouteYaml>,
#[serde(default)]
pub plugins: Vec<PluginDeclaration>,
#[serde(flatten)]
pub other: HashMap<String, serde_yaml::Value>,
}
#[derive(Debug, Default, Deserialize)]
pub struct RouteYaml {
#[serde(default)]
pub policy: Vec<serde_yaml::Value>,
#[serde(default)]
pub post_policy: Vec<serde_yaml::Value>,
#[serde(default)]
pub args: HashMap<String, String>,
#[serde(default)]
pub result: HashMap<String, String>,
#[serde(default)]
pub plugins: HashMap<String, PluginOverride>,
#[serde(flatten)]
pub other: HashMap<String, serde_yaml::Value>,
}
#[derive(Debug, Default)]
pub struct CompiledConfig {
pub routes: HashMap<String, CompiledRoute>,
pub plugins: PluginRegistry,
}
pub fn compile_config(yaml: &str) -> Result<CompiledConfig, ParseError> {
let cfg: ConfigYaml = serde_yaml::from_str(yaml)?;
let mut routes = HashMap::with_capacity(cfg.routes.len());
for (route_key, raw) in cfg.routes {
if let Some(route) = compile_route(&route_key, raw)? {
routes.insert(route_key, route);
}
}
let mut plugins = PluginRegistry::with_capacity(cfg.plugins.len());
for decl in cfg.plugins {
plugins.insert(decl.name.clone(), decl);
}
Ok(CompiledConfig { routes, plugins })
}
fn compile_route(route_key: &str, raw: RouteYaml) -> Result<Option<CompiledRoute>, ParseError> {
let has_apl = !raw.policy.is_empty()
|| !raw.post_policy.is_empty()
|| !raw.args.is_empty()
|| !raw.result.is_empty();
if !has_apl {
return Ok(None);
}
Ok(Some(compile_apl_blocks(route_key, raw)?))
}
fn compile_apl_blocks(source: &str, raw: RouteYaml) -> Result<CompiledRoute, ParseError> {
let mut route = CompiledRoute::new(source);
for (i, entry) in raw.policy.iter().enumerate() {
let step = parse_step(entry, &format!("{}.policy[{}]", source, i))?;
route.policy.push(step_to_top_level_effect(step)?);
}
for (i, entry) in raw.post_policy.iter().enumerate() {
let step = parse_step(entry, &format!("{}.post_policy[{}]", source, i))?;
route.post_policy.push(step_to_top_level_effect(step)?);
}
for (field, chain) in &raw.args {
let pipeline = parse_pipeline(chain).map_err(|e| ParseError::Rule {
rule: format!("args.{}: {:?}", field, chain),
msg: format!("{}", e),
})?;
route.args.push(FieldRule {
field: field.clone(),
pipeline,
source: format!("{}.args.{}", source, field),
});
}
for (field, chain) in &raw.result {
let pipeline = parse_pipeline(chain).map_err(|e| ParseError::Rule {
rule: format!("result.{}: {:?}", field, chain),
msg: format!("{}", e),
})?;
route.result.push(FieldRule {
field: field.clone(),
pipeline,
source: format!("{}.result.{}", source, field),
});
}
route.plugin_overrides = raw.plugins;
Ok(route)
}
pub fn compile_policy_block_value(
source: &str,
block: &serde_yaml::Value,
) -> Result<CompiledRoute, ParseError> {
if block.is_null() {
return Ok(CompiledRoute::new(source));
}
let raw: RouteYaml = serde_yaml::from_value(block.clone())?;
compile_apl_blocks(source, raw)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::attributes::AttributeBag;
use crate::evaluator::Decision;
#[test]
fn lex_basic() {
let toks = Lexer::new("delegation.depth > 2").tokenize_all().unwrap();
assert_eq!(
toks,
vec![
Tok::Ident("delegation.depth".into()),
Tok::Gt,
Tok::IntLit(2),
]
);
}
#[test]
fn lex_strings_both_quotes() {
let a = Lexer::new(r#""double""#).tokenize_all().unwrap();
let b = Lexer::new(r#"'single'"#).tokenize_all().unwrap();
assert_eq!(a, vec![Tok::StringLit("double".into())]);
assert_eq!(b, vec![Tok::StringLit("single".into())]);
}
#[test]
fn lex_keywords_vs_idents() {
let toks = Lexer::new("require(role.hr) & authenticated")
.tokenize_all()
.unwrap();
assert_eq!(
toks,
vec![
Tok::Require,
Tok::LParen,
Tok::Ident("role.hr".into()),
Tok::RParen,
Tok::And,
Tok::Ident("authenticated".into()),
]
);
}
#[test]
fn lex_rejects_single_equals() {
let err = Lexer::new("a = 1").tokenize_all().unwrap_err();
assert!(format!("{}", err).contains("expected `==`"));
}
#[test]
fn pred_bare_identifier() {
let e = parse_predicate("authenticated").unwrap();
assert_eq!(
e,
Expression::Condition(Condition::IsTrue {
key: "authenticated".into()
})
);
}
#[test]
fn pred_comparison() {
let e = parse_predicate("delegation.depth > 2").unwrap();
assert_eq!(
e,
Expression::Condition(Condition::Comparison {
key: "delegation.depth".into(),
op: CompareOp::Gt,
value: Literal::Int(2),
})
);
}
#[test]
fn pred_contains() {
let e = parse_predicate(r#"session.labels contains "PII""#).unwrap();
assert_eq!(
e,
Expression::Condition(Condition::Comparison {
key: "session.labels".into(),
op: CompareOp::Contains,
value: Literal::String("PII".into()),
})
);
}
#[test]
fn pred_precedence_or_lowest_and_middle_not_highest() {
let e = parse_predicate("!a & b | c").unwrap();
match e {
Expression::Or(parts) => {
assert_eq!(parts.len(), 2);
match &parts[0] {
Expression::And(_) => {},
other => panic!("first OR branch should be AND, got {:?}", other),
}
},
other => panic!("top-level should be OR, got {:?}", other),
}
}
#[test]
fn pred_parens_override_precedence() {
let e = parse_predicate("(role.finance | role.admin) & !delegated").unwrap();
match e {
Expression::And(parts) => {
assert_eq!(parts.len(), 2);
matches!(parts[0], Expression::Or(_));
matches!(parts[1], Expression::Not(_));
},
other => panic!("expected top-level AND, got {:?}", other),
}
}
#[test]
fn pred_require_rejected_as_predicate() {
let err = parse_predicate("require(authenticated)").unwrap_err();
assert!(format!("{}", err).contains("rule-level shorthand"));
}
#[test]
fn rule_require_single_arg_desugars_to_isfalse_and_deny() {
let r = parse_rule("require(authenticated)", "test").unwrap();
assert!(matches!(
r.effects.as_slice(),
[Effect::Deny {
reason: None,
code: None
}]
));
assert_eq!(
r.condition,
Expression::Condition(Condition::IsFalse {
key: "authenticated".into()
}),
);
}
#[test]
fn rule_require_comma_is_and_desugars_to_or_of_isfalse() {
let r = parse_rule("require(role.hr, perm.view_ssn)", "test").unwrap();
assert_eq!(
r.condition,
Expression::Or(vec![
Expression::Condition(Condition::IsFalse {
key: "role.hr".into()
}),
Expression::Condition(Condition::IsFalse {
key: "perm.view_ssn".into()
}),
]),
);
}
#[test]
fn rule_require_pipe_is_or_desugars_to_and_of_isfalse() {
let r = parse_rule("require(role.finance | role.admin)", "test").unwrap();
assert_eq!(
r.condition,
Expression::And(vec![
Expression::Condition(Condition::IsFalse {
key: "role.finance".into()
}),
Expression::Condition(Condition::IsFalse {
key: "role.admin".into()
}),
]),
);
}
#[test]
fn rule_require_mixed_rejected() {
let err = parse_rule("require(a, b | c)", "test").unwrap_err();
assert!(format!("{}", err).contains("cannot mix"));
}
#[test]
fn pred_eq_with_ident_rhs_rejected_with_in_hint() {
let err = parse_predicate("subject.type == allowed_types").unwrap_err();
let msg = format!("{}", err);
assert!(msg.contains("RHS-as-identifier"));
assert!(msg.contains("set membership use"));
}
#[test]
fn pred_in_set_basic() {
let e = parse_predicate("subject.type in allowed_types").unwrap();
assert_eq!(
e,
Expression::Condition(Condition::InSet {
value_key: "subject.type".into(),
set_key: "allowed_types".into(),
negate: false,
}),
);
}
#[test]
fn pred_not_in_set() {
let e = parse_predicate("subject.type not in blocked_types").unwrap();
assert_eq!(
e,
Expression::Condition(Condition::InSet {
value_key: "subject.type".into(),
set_key: "blocked_types".into(),
negate: true,
}),
);
}
#[test]
fn pred_exists_basic() {
let e = parse_predicate("exists(args.amount)").unwrap();
assert_eq!(
e,
Expression::Condition(Condition::Exists {
key: "args.amount".into()
}),
);
}
#[test]
fn pred_exists_inside_compound() {
let e = parse_predicate("exists(args.amount) & args.amount > 0").unwrap();
match e {
Expression::And(parts) => {
assert_eq!(parts.len(), 2);
assert_eq!(
parts[0],
Expression::Condition(Condition::Exists {
key: "args.amount".into()
}),
);
},
other => panic!("expected And, got {:?}", other),
}
}
#[test]
fn pred_exists_requires_paren_and_ident() {
assert!(parse_predicate("exists").is_err());
assert!(parse_predicate("exists()").is_err());
assert!(parse_predicate("exists(authenticated").is_err());
}
#[test]
fn pred_trailing_tokens_rejected() {
let err = parse_predicate("a b").unwrap_err();
assert!(format!("{}", err).contains("trailing"));
}
#[test]
fn rule_predicate_action_form() {
let r = parse_rule("delegation.depth > 2: deny", "test").unwrap();
match r.effects.as_slice() {
[Effect::Deny { .. }] => {},
other => panic!("expected [Deny], got {:?}", other),
}
match r.condition {
Expression::Condition(Condition::Comparison { .. }) => {},
other => panic!("expected Comparison, got {:?}", other),
}
}
#[test]
fn rule_predicate_only_defaults_to_deny() {
let r = parse_rule("!authenticated", "test").unwrap();
assert!(matches!(r.effects.as_slice(), [Effect::Deny { .. }]));
}
#[test]
fn rule_explicit_allow() {
let r = parse_rule("role.admin: allow", "test").unwrap();
assert!(matches!(r.effects.as_slice(), [Effect::Allow]));
}
#[test]
fn rule_bare_action_unconditional() {
let r = parse_rule("deny", "test").unwrap();
assert_eq!(r.condition, Expression::Always);
assert!(matches!(
r.effects.as_slice(),
[Effect::Deny {
reason: None,
code: None
}]
));
let r = parse_rule("allow", "test").unwrap();
assert_eq!(r.condition, Expression::Always);
assert!(matches!(r.effects.as_slice(), [Effect::Allow]));
}
#[test]
fn rule_bare_deny_call_carries_reason_and_code() {
let r = parse_rule("deny('nope')", "test").unwrap();
assert_eq!(r.condition, Expression::Always);
match r.effects.as_slice() {
[Effect::Deny {
reason: Some(reason),
code: None,
}] => assert_eq!(reason, "nope"),
other => panic!(
"expected [Deny{{reason: Some, code: None}}], got {:?}",
other
),
}
let r = parse_rule("deny('nope', 'cel.policy')", "test").unwrap();
assert_eq!(r.condition, Expression::Always);
match r.effects.as_slice() {
[Effect::Deny {
reason: Some(reason),
code: Some(code),
}] => {
assert_eq!(reason, "nope");
assert_eq!(code, "cel.policy");
},
other => panic!("expected [Deny{{reason, code}}], got {:?}", other),
}
}
#[test]
fn rule_malformed_bare_deny_call_errors() {
let err = parse_rule("deny(unquoted)", "test").unwrap_err();
assert!(
matches!(err, ParseError::Rule { .. }),
"expected ParseError::Rule, got {:?}",
err
);
}
#[test]
fn rule_step_kinds_rejected_clearly() {
for s in [
"plugin(rate_limiter)",
"cedar:(action: read)",
"opa(path)",
"taint(audit)",
] {
let err = parse_rule(s, "test").unwrap_err();
assert!(
matches!(err, ParseError::UnsupportedStep { .. }),
"expected UnsupportedStep for `{}`, got {:?}",
s,
err
);
}
}
#[test]
fn rule_deny_with_unquoted_arg_rejected() {
let err = parse_rule(r#"authenticated: deny "go away""#, "test").unwrap_err();
assert!(format!("{}", err).contains("unsupported action"));
}
#[test]
fn rule_deny_with_quoted_reason_accepted() {
let r = parse_rule(r#"delegation.depth > 2: deny('too deep')"#, "test").unwrap();
assert!(matches!(
r.effects.as_slice(),
[Effect::Deny { reason: Some(s), code: None }] if s == "too deep"
));
}
#[test]
fn rule_deny_with_reason_and_code_accepted() {
let r = parse_rule(
r#"delegation.depth > 2: deny('too deep', 'delegation.depth_exceeded')"#,
"test",
)
.unwrap();
match r.effects.as_slice() {
[Effect::Deny {
reason: Some(reason),
code: Some(code),
}] => {
assert_eq!(reason, "too deep");
assert_eq!(code, "delegation.depth_exceeded");
},
other => panic!("expected Deny with reason+code, got {:?}", other),
}
}
#[test]
fn rule_deny_with_too_many_args_rejected() {
let err = parse_rule(r#"x: deny('a', 'b', 'c')"#, "test").unwrap_err();
assert!(format!("{}", err).contains("at most two args"));
}
#[test]
fn rule_deny_with_unquoted_args_in_call_rejected() {
let err = parse_rule(r#"x: deny(bare, identifier)"#, "test").unwrap_err();
assert!(format!("{}", err).contains("expected a quoted string"));
}
fn parse_step_yaml(yaml: &str) -> Result<Step, ParseError> {
let v: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
parse_step(&v, "test")
}
#[test]
fn when_do_single_effect_deny() {
let step = parse_step_yaml("when: delegation.depth > 2\ndo: deny").unwrap();
match step {
Step::Rule(rule) => {
assert!(matches!(
rule.condition,
Expression::Condition(Condition::Comparison { .. })
));
assert!(matches!(
rule.effects.as_slice(),
[Effect::Deny {
reason: None,
code: None
}]
));
},
other => panic!("expected Step::Rule, got {:?}", other),
}
}
#[test]
fn when_do_single_effect_deny_with_reason_and_code() {
let step = parse_step_yaml(
"when: delegation.depth > 2\ndo: deny('too deep', 'delegation.depth_exceeded')",
)
.unwrap();
let Step::Rule(rule) = step else {
panic!("expected Step::Rule");
};
match rule.effects.as_slice() {
[Effect::Deny {
reason: Some(r),
code: Some(c),
}] => {
assert_eq!(r, "too deep");
assert_eq!(c, "delegation.depth_exceeded");
},
other => panic!("expected Deny+reason+code, got {:?}", other),
}
}
#[test]
fn when_do_multi_effect_list() {
let yaml = r#"
when: "!role.hr"
do:
- "plugin(audit_logger)"
- "taint(unauth, session)"
- "deny('refused', 'role.hr_required')"
"#;
let step = parse_step_yaml(yaml).unwrap();
let Step::Rule(rule) = step else {
panic!("expected Step::Rule");
};
assert_eq!(rule.effects.len(), 3);
assert!(matches!(rule.effects[0], Effect::Plugin { ref name } if name == "audit_logger"));
assert!(matches!(
rule.effects[1],
Effect::Taint { ref label, .. } if label == "unauth"
));
match &rule.effects[2] {
Effect::Deny {
reason: Some(r),
code: Some(c),
} => {
assert_eq!(r, "refused");
assert_eq!(c, "role.hr_required");
},
other => panic!("expected Deny+reason+code, got {:?}", other),
}
}
#[test]
fn when_do_key_order_does_not_matter() {
let step = parse_step_yaml("do: deny\nwhen: delegation.depth > 2").unwrap();
assert!(matches!(step, Step::Rule(_)));
}
#[test]
fn when_do_with_unknown_key_rejected() {
let err = parse_step_yaml("when: x\ndo: deny\nwhne: typo").unwrap_err();
assert!(format!("{}", err).contains("unexpected key"));
}
#[test]
fn when_do_empty_do_list_rejected() {
let err = parse_step_yaml("when: x\ndo: []").unwrap_err();
assert!(format!("{}", err).contains("no effects"));
}
#[test]
fn shorthand_multi_effect_map() {
let yaml = r#"
"!role.hr":
- "plugin(audit_logger)"
- "deny('unauthorized')"
"#;
let step = parse_step_yaml(yaml).unwrap();
let Step::Rule(rule) = step else {
panic!("expected Step::Rule");
};
assert_eq!(rule.effects.len(), 2);
assert!(matches!(rule.effects[0], Effect::Plugin { ref name } if name == "audit_logger"));
assert!(matches!(
rule.effects[1],
Effect::Deny { reason: Some(ref r), code: None } if r == "unauthorized"
));
}
#[test]
fn shorthand_multi_effect_map_with_nested_delegate() {
let yaml = r#"
"role.hr":
- delegate:
plugin: workday-oauth
config:
audience: workday-api
- "plugin(audit_logger)"
"#;
let step = parse_step_yaml(yaml).unwrap();
let Step::Rule(rule) = step else {
panic!("expected Step::Rule");
};
assert_eq!(rule.effects.len(), 2);
assert!(matches!(rule.effects[0], Effect::Delegate(_)));
assert!(matches!(rule.effects[1], Effect::Plugin { .. }));
}
#[test]
fn cedar_with_list_body_still_parses_as_pdp() {
let err = parse_step_yaml("cedar: [oh no]").unwrap_err();
assert!(format!("{}", err).contains("body must be a map"));
}
#[test]
fn shorthand_multi_effect_empty_list_rejected() {
let err = parse_step_yaml(r#""x": []"#).unwrap_err();
assert!(format!("{}", err).contains("no effects"));
}
#[test]
fn when_do_with_field_op_result_redact() {
let yaml = r#"
when: "!perm.view_ssn"
do:
- "plugin(audit_logger)"
- "result.salary | redact"
"#;
let step = parse_step_yaml(yaml).unwrap();
let Step::Rule(rule) = step else {
panic!("expected Step::Rule");
};
assert_eq!(rule.effects.len(), 2);
assert!(matches!(rule.effects[0], Effect::Plugin { .. }));
match &rule.effects[1] {
Effect::FieldOp { path, stages } => {
assert_eq!(path, "result.salary");
assert_eq!(stages.len(), 1, "single `redact` stage");
},
other => panic!("expected FieldOp, got {:?}", other),
}
}
#[test]
fn when_do_with_field_op_args_mask() {
let yaml = r#"
when: role.support
do: "args.card_number | mask(4)"
"#;
let step = parse_step_yaml(yaml).unwrap();
let Step::Rule(rule) = step else {
panic!("expected Step::Rule");
};
match &rule.effects[..] {
[Effect::FieldOp { path, stages }] => {
assert_eq!(path, "args.card_number");
assert_eq!(stages.len(), 1);
},
other => panic!("expected single FieldOp, got {:?}", other),
}
}
#[test]
fn when_do_with_chained_field_op() {
let yaml = r#"
when: role.support
do: "args.card_number | str | mask(4)"
"#;
let step = parse_step_yaml(yaml).unwrap();
let Step::Rule(rule) = step else {
panic!("expected Step::Rule");
};
match &rule.effects[..] {
[Effect::FieldOp { path, stages }] => {
assert_eq!(path, "args.card_number");
assert_eq!(stages.len(), 2, "two-stage chain");
},
other => panic!("expected single FieldOp, got {:?}", other),
}
}
#[test]
fn field_stage_run_aliases_plugin() {
let yaml = r#"
when: role.support
do: "args.card_number | run(luhn)"
"#;
let step = parse_step_yaml(yaml).unwrap();
let Step::Rule(rule) = step else {
panic!("expected Step::Rule");
};
match &rule.effects[..] {
[Effect::FieldOp { path, stages }] => {
assert_eq!(path, "args.card_number");
match &stages[..] {
[Stage::Plugin { name }] => assert_eq!(name, "luhn"),
other => panic!("expected [Stage::Plugin], got {:?}", other),
}
},
other => panic!("expected single FieldOp, got {:?}", other),
}
}
#[test]
fn field_stage_plugin_empty_name_is_rejected() {
for verb in ["plugin", "run"] {
let err = parse_stage(&format!("{verb}()")).expect_err("empty name must error");
let msg = format!("{err}");
assert!(
msg.contains(verb) && msg.contains("must not be empty"),
"{verb}(): expected verb-named empty-name error, got: {msg}"
);
}
}
#[test]
fn field_op_invalid_path_falls_through() {
let yaml = r#"do: "role.hr | redact""#;
let _ = parse_step_yaml(&format!("when: true\n{}", yaml));
let step = parse_step_yaml("when: true\ndo: \"role.hr | redact\"");
match step {
Ok(Step::Rule(rule)) => {
assert!(
!matches!(rule.effects.as_slice(), [Effect::FieldOp { .. }]),
"bare `role.hr` must NOT parse as a FieldOp path"
);
},
Err(_) => {}, other => panic!("unexpected: {:?}", other),
}
}
#[test]
fn field_op_empty_chain_rejected() {
let yaml = r#"when: true
do: "args.x | ""#;
let _ = parse_step_yaml(yaml); }
#[test]
fn shorthand_multi_effect_with_field_op() {
let yaml = r#"
"!perm.view_ssn":
- "plugin(audit_logger)"
- "result.ssn | redact"
"#;
let step = parse_step_yaml(yaml).unwrap();
let Step::Rule(rule) = step else {
panic!("expected Step::Rule");
};
assert_eq!(rule.effects.len(), 2);
assert!(matches!(rule.effects[1], Effect::FieldOp { .. }));
}
#[test]
fn find_top_level_pipe_skips_inside_parens() {
assert_eq!(find_top_level_pipe("args.x | mask(4)"), Some(7));
assert_eq!(find_top_level_pipe("validate(luhn)"), None);
assert_eq!(find_top_level_pipe(r#"args.x | mask("a|b")"#), Some(7));
assert_eq!(find_top_level_pipe("mask(a|b)"), None);
}
#[test]
fn top_level_sequential() {
let yaml = r#"
sequential:
- "plugin(rate_limiter)"
- "plugin(audit_logger)"
"#;
let step = parse_step_yaml(yaml).unwrap();
let Step::Rule(rule) = step else {
panic!("expected Rule");
};
assert!(matches!(rule.condition, Expression::Always));
match rule.effects.as_slice() {
[Effect::Sequential(inner)] => {
assert_eq!(inner.len(), 2);
assert!(matches!(inner[0], Effect::Plugin { .. }));
assert!(matches!(inner[1], Effect::Plugin { .. }));
},
other => panic!("expected single Sequential effect, got {:?}", other),
}
}
#[test]
fn top_level_parallel() {
let yaml = r#"
parallel:
- "plugin(pii_scanner)"
- "plugin(nemo_guardrails)"
"#;
let step = parse_step_yaml(yaml).unwrap();
let Step::Rule(rule) = step else {
panic!("expected Rule");
};
match rule.effects.as_slice() {
[Effect::Parallel(inner)] => {
assert_eq!(inner.len(), 2);
},
other => panic!("expected single Parallel effect, got {:?}", other),
}
}
#[test]
fn parallel_inside_do_body() {
let yaml = r#"
when: args.include_ssn == true
do:
parallel:
- "plugin(pii_scanner)"
- "plugin(nemo_guardrails)"
"#;
let step = parse_step_yaml(yaml).unwrap();
let Step::Rule(rule) = step else {
panic!("expected Rule");
};
match rule.effects.as_slice() {
[Effect::Parallel(inner)] => assert_eq!(inner.len(), 2),
other => panic!("expected Parallel in do:, got {:?}", other),
}
}
#[test]
fn parallel_rejects_field_op_at_parse_time() {
let yaml = r#"
parallel:
- "plugin(audit)"
- "args.ssn | redact"
"#;
let err = parse_step_yaml(yaml).unwrap_err();
assert!(format!("{}", err).contains("mutation"), "got: {}", err);
}
#[test]
fn parallel_rejects_delegate_at_parse_time() {
let yaml = r#"
parallel:
- "plugin(audit)"
- "delegate(workday)"
"#;
let err = parse_step_yaml(yaml).unwrap_err();
assert!(format!("{}", err).contains("mutation"));
}
#[test]
fn sequential_allows_mutations() {
let yaml = r#"
sequential:
- "args.ssn | redact"
- "plugin(audit)"
"#;
let step = parse_step_yaml(yaml).unwrap();
let Step::Rule(rule) = step else {
panic!("expected Rule")
};
match rule.effects.as_slice() {
[Effect::Sequential(inner)] => {
assert!(matches!(inner[0], Effect::FieldOp { .. }));
assert!(matches!(inner[1], Effect::Plugin { .. }));
},
other => panic!("got {:?}", other),
}
}
#[test]
fn parallel_empty_list_rejected() {
let err = parse_step_yaml("parallel: []").unwrap_err();
assert!(format!("{}", err).contains("empty"));
}
#[test]
fn sequential_empty_list_rejected() {
let err = parse_step_yaml("sequential: []").unwrap_err();
assert!(format!("{}", err).contains("empty"));
}
#[test]
fn nested_orchestration() {
let yaml = r#"
sequential:
- "plugin(rate_limiter)"
- parallel:
- "plugin(pii_scanner)"
- "plugin(nemo)"
"#;
let step = parse_step_yaml(yaml).unwrap();
let Step::Rule(rule) = step else {
panic!("expected Rule")
};
let Effect::Sequential(outer) = &rule.effects[0] else {
panic!("expected Sequential");
};
assert_eq!(outer.len(), 2);
assert!(matches!(outer[0], Effect::Plugin { .. }));
match &outer[1] {
Effect::Parallel(inner) => assert_eq!(inner.len(), 2),
other => panic!("expected nested Parallel, got {:?}", other),
}
}
#[test]
fn split_respects_quotes_and_parens() {
let r = parse_rule(r#"session.labels contains "a:b": deny"#, "test").unwrap();
assert!(matches!(r.effects.as_slice(), [Effect::Deny { .. }]));
if let Expression::Condition(Condition::Comparison { value, .. }) = r.condition {
assert_eq!(value, Literal::String("a:b".into()));
} else {
panic!("expected Comparison");
}
}
#[test]
fn compile_simple_route() {
let yaml = r#"
routes:
get_compensation:
policy:
- "require(authenticated)"
- "require(role.hr | role.finance)"
- "delegation.depth > 2 & include_ssn: deny"
"#;
let routes = compile_config(yaml).unwrap().routes;
let route = routes.get("get_compensation").expect("route missing");
assert_eq!(route.policy.len(), 3);
assert!(route
.declared_phases()
.contains(crate::rules::Phase::Policy));
}
#[test]
fn compile_omits_routes_without_apl_blocks() {
let yaml = r#"
routes:
legacy:
priority: 50
apl_route:
policy:
- "require(authenticated)"
"#;
let routes = compile_config(yaml).unwrap().routes;
assert!(routes.contains_key("apl_route"));
assert!(
!routes.contains_key("legacy"),
"legacy route should be omitted, not compiled"
);
}
#[test]
fn compile_unknown_top_level_keys_ignored() {
let yaml = r#"
version: "0.1"
policy_evaluator:
kind: apl
plugins:
- name: rate_limiter
kind: native
imports:
- "./shared.yaml"
routes:
ping:
policy:
- "require(authenticated)"
"#;
let routes = compile_config(yaml).unwrap().routes;
assert!(routes.contains_key("ping"));
}
#[test]
fn compile_propagates_rule_errors_with_source() {
let yaml = r#"
routes:
bad:
policy:
- "subject.id == garbage_ident"
"#;
let err = compile_config(yaml).unwrap_err();
let msg = format!("{}", err);
assert!(
msg.contains("RHS-as-identifier") || msg.contains("garbage_ident"),
"error message should reference the failure: {}",
msg,
);
}
#[test]
fn compile_plugin_step_string_form() {
let yaml = r#"
routes:
rate_limited:
policy:
- "plugin(rate_limiter)"
"#;
let routes = compile_config(yaml).unwrap().routes;
let route = routes.get("rate_limited").unwrap();
assert_eq!(route.policy.len(), 1);
match &route.policy[0] {
Effect::Plugin { name } => assert_eq!(name, "rate_limiter"),
other => panic!("expected Effect::Plugin, got {:?}", other),
}
}
#[test]
fn compile_run_step_string_form_aliases_plugin() {
let yaml = r#"
routes:
rate_limited:
policy:
- "run(rate_limiter)"
"#;
let routes = compile_config(yaml).unwrap().routes;
let route = routes.get("rate_limited").unwrap();
assert_eq!(route.policy.len(), 1);
match &route.policy[0] {
Effect::Plugin { name } => assert_eq!(name, "rate_limiter"),
other => panic!("expected Effect::Plugin, got {:?}", other),
}
}
#[test]
fn parse_step_run_is_plugin_alias() {
for s in ["run(audit-log)", "plugin(audit-log)"] {
let step = parse_step(&serde_yaml::Value::String(s.to_string()), "test").unwrap();
match step {
crate::step::Step::Plugin { name } => assert_eq!(name, "audit-log", "{s}"),
other => panic!("expected Step::Plugin for `{s}`, got {other:?}"),
}
}
let err = parse_step(&serde_yaml::Value::String("run()".to_string()), "test").unwrap_err();
assert!(
format!("{err}").contains("run("),
"error should name `run(...)`: {err}"
);
}
#[test]
fn compile_taint_step_string_form() {
let yaml = r#"
routes:
audit_marked:
policy:
- "taint(audit, session)"
"#;
let routes = compile_config(yaml).unwrap().routes;
let route = routes.get("audit_marked").unwrap();
match &route.policy[0] {
Effect::Taint { label, scopes } => {
assert_eq!(label, "audit");
assert_eq!(scopes, &vec![TaintScope::Session]);
},
other => panic!("expected Effect::Taint, got {:?}", other),
}
}
#[test]
fn compile_pdp_call_cedar_map_form() {
let yaml = r#"
routes:
authz_check:
policy:
- cedar:
action: read
resource: employee
on_deny:
- deny
on_allow:
- "plugin(audit_logger)"
"#;
let routes = compile_config(yaml).unwrap().routes;
let route = routes.get("authz_check").unwrap();
match &route.policy[0] {
Effect::Pdp {
call,
on_deny,
on_allow,
} => {
assert_eq!(call.dialect, PdpDialect::Cedar);
let args_map = call.args.as_mapping().expect("cedar args should be a map");
assert!(args_map.contains_key(serde_yaml::Value::String("action".into())));
assert!(args_map.contains_key(serde_yaml::Value::String("resource".into())));
assert!(!args_map.contains_key(serde_yaml::Value::String("on_deny".into())));
assert_eq!(on_deny.len(), 1);
assert_eq!(on_allow.len(), 1);
},
other => panic!("expected Effect::Pdp, got {:?}", other),
}
}
#[test]
fn compile_pdp_call_cel_map_form() {
let yaml = r#"
routes:
authz_check:
policy:
- cel:
expr: "subject.id == 'alice' && delegation.depth <= 2"
on_deny:
- deny
"#;
let routes = compile_config(yaml).unwrap().routes;
let route = routes.get("authz_check").unwrap();
match &route.policy[0] {
Effect::Pdp {
call,
on_deny,
on_allow,
} => {
assert_eq!(call.dialect, PdpDialect::Cel);
let args_map = call.args.as_mapping().expect("cel args should be a map");
assert!(args_map.contains_key(serde_yaml::Value::String("expr".into())));
assert!(!args_map.contains_key(serde_yaml::Value::String("on_deny".into())));
assert_eq!(on_deny.len(), 1);
assert_eq!(on_allow.len(), 0);
},
other => panic!("expected Effect::Pdp, got {:?}", other),
}
}
#[test]
fn compile_pdp_call_opa_paren_form() {
let yaml = r#"
routes:
opa_check:
policy:
- 'opa("hr/compensation/deny"):':
on_deny:
- deny
"#;
let routes = compile_config(yaml).unwrap().routes;
let route = routes.get("opa_check").unwrap();
match &route.policy[0] {
Effect::Pdp { call, on_deny, .. } => {
assert_eq!(call.dialect, PdpDialect::Opa);
assert!(call.args.as_str().unwrap().contains("hr/compensation/deny"));
assert_eq!(on_deny.len(), 1);
},
other => panic!("expected Effect::Pdp, got {:?}", other),
}
}
#[test]
fn compile_pdp_unknown_dialect_becomes_custom() {
let yaml = r#"
routes:
custom_pdp:
policy:
- my_engine:
on_deny: [deny]
"#;
let routes = compile_config(yaml).unwrap().routes;
match &routes.get("custom_pdp").unwrap().policy[0] {
Effect::Pdp { call, .. } => {
assert_eq!(call.dialect, PdpDialect::Custom("my_engine".into()));
},
other => panic!("expected Pdp, got {:?}", other),
}
}
#[tokio::test]
async fn end_to_end_hr_compensation() {
let yaml = r#"
routes:
get_compensation:
policy:
- "require(authenticated)"
- "require(role.hr | role.finance)"
- "delegation.depth > 2: deny"
"#;
let routes = compile_config(yaml).unwrap().routes;
let route = routes.get("get_compensation").unwrap();
let pdp: std::sync::Arc<dyn crate::PdpResolver> = std::sync::Arc::new(NullPdpResolver);
let plugins: std::sync::Arc<dyn crate::PluginInvoker> =
std::sync::Arc::new(NullPluginInvoker);
let delegations: std::sync::Arc<dyn crate::DelegationInvoker> =
std::sync::Arc::new(crate::NoopDelegationInvoker);
let mut bag = AttributeBag::new();
bag.set("authenticated", true);
bag.set("role.hr", true);
bag.set("delegation.depth", 1_i64);
assert_eq!(
crate::evaluate_effects(
&route.policy,
&mut bag,
&pdp,
&plugins,
&delegations,
crate::DispatchPhase::Pre,
&mut crate::route::RoutePayload::new(serde_json::Value::Null)
)
.await
.decision,
Decision::Allow,
);
bag.set("delegation.depth", 3_i64);
match crate::evaluate_effects(
&route.policy,
&mut bag,
&pdp,
&plugins,
&delegations,
crate::DispatchPhase::Pre,
&mut crate::route::RoutePayload::new(serde_json::Value::Null),
)
.await
.decision
{
Decision::Deny { rule_source, .. } => {
assert!(
rule_source.contains("policy[2]"),
"expected policy[2], got {}",
rule_source
);
},
d => panic!("expected Deny, got {:?}", d),
}
let mut bag = AttributeBag::new();
bag.set("authenticated", true);
bag.set("delegation.depth", 1_i64);
match crate::evaluate_effects(
&route.policy,
&mut bag,
&pdp,
&plugins,
&delegations,
crate::DispatchPhase::Pre,
&mut crate::route::RoutePayload::new(serde_json::Value::Null),
)
.await
.decision
{
Decision::Deny { rule_source, .. } => {
assert!(
rule_source.contains("policy[1]"),
"expected policy[1], got {}",
rule_source
);
},
d => panic!("expected Deny, got {:?}", d),
}
}
struct NullPdpResolver;
#[async_trait::async_trait]
impl crate::PdpResolver for NullPdpResolver {
fn dialect(&self) -> crate::PdpDialect {
crate::PdpDialect::Cedar
}
async fn evaluate(
&self,
_call: &crate::PdpCall,
_bag: &crate::AttributeBag,
) -> Result<crate::PdpDecision, crate::PdpError> {
panic!("NullPdpResolver should not be invoked in pure-rule tests");
}
}
struct NullPluginInvoker;
#[async_trait::async_trait]
impl crate::PluginInvoker for NullPluginInvoker {
async fn invoke(
&self,
_name: &str,
_bag: &crate::AttributeBag,
_invocation: crate::PluginInvocation<'_>,
) -> Result<crate::PluginOutcome, crate::PluginError> {
panic!("NullPluginInvoker should not be invoked in pure-rule tests");
}
}
#[test]
fn pipeline_simple_bare_stages() {
let p = parse_pipeline("str").unwrap();
assert_eq!(p.stages, vec![Stage::Type(TypeCheck::Str)]);
let p = parse_pipeline("omit").unwrap();
assert_eq!(p.stages, vec![Stage::Omit]);
let p = parse_pipeline("hash").unwrap();
assert_eq!(p.stages, vec![Stage::Hash]);
}
#[test]
fn pipeline_chains_split_on_pipe() {
let p = parse_pipeline("str | mask(4)").unwrap();
assert_eq!(
p.stages,
vec![Stage::Type(TypeCheck::Str), Stage::Mask { keep_last: 4 },]
);
let p = parse_pipeline("int | 0..1M").unwrap();
assert_eq!(
p.stages,
vec![
Stage::Type(TypeCheck::Int),
Stage::Range {
min: Some(0),
max: Some(1_000_000)
},
]
);
}
#[test]
fn pipeline_pipe_inside_parens_does_not_split() {
let p = parse_pipeline("str | redact(!perm.view_ssn | role.admin)").unwrap();
assert_eq!(p.stages.len(), 2);
match &p.stages[1] {
Stage::Redact { condition: Some(_) } => {},
other => panic!("expected Redact with condition, got {:?}", other),
}
}
#[test]
fn pipeline_length_constraints() {
let p = parse_pipeline("len(..500)").unwrap();
assert_eq!(
p.stages,
vec![Stage::Length {
min: None,
max: Some(500)
}]
);
let p = parse_pipeline("len(10..50)").unwrap();
assert_eq!(
p.stages,
vec![Stage::Length {
min: Some(10),
max: Some(50)
}]
);
let p = parse_pipeline("len(8..)").unwrap();
assert_eq!(
p.stages,
vec![Stage::Length {
min: Some(8),
max: None
}]
);
}
#[test]
fn pipeline_range_with_suffixes() {
let p = parse_pipeline("0..10k").unwrap();
assert_eq!(
p.stages,
vec![Stage::Range {
min: Some(0),
max: Some(10_000)
}]
);
let p = parse_pipeline("0..1M").unwrap();
assert_eq!(
p.stages,
vec![Stage::Range {
min: Some(0),
max: Some(1_000_000)
}]
);
let p = parse_pipeline("..500").unwrap();
assert_eq!(
p.stages,
vec![Stage::Range {
min: None,
max: Some(500)
}]
);
}
#[test]
fn pipeline_enum_unquoted_and_quoted() {
let p = parse_pipeline("enum(low, medium, high)").unwrap();
assert_eq!(
p.stages,
vec![Stage::Enum {
values: vec!["low".into(), "medium".into(), "high".into()],
}]
);
let p = parse_pipeline(r#"enum("a", "b")"#).unwrap();
assert_eq!(
p.stages,
vec![Stage::Enum {
values: vec!["a".into(), "b".into()],
}]
);
}
#[test]
fn pipeline_redact_with_predicate_condition() {
let p = parse_pipeline("str | redact(!perm.view_ssn)").unwrap();
assert_eq!(p.stages.len(), 2);
match &p.stages[1] {
Stage::Redact {
condition: Some(Expression::Not(inner)),
} => match inner.as_ref() {
Expression::Condition(Condition::IsTrue { key }) => {
assert_eq!(key, "perm.view_ssn");
},
other => panic!("expected IsTrue(perm.view_ssn), got {:?}", other),
},
other => panic!("expected Redact with Not condition, got {:?}", other),
}
}
#[test]
fn pipeline_taint_scopes() {
let p = parse_pipeline("taint(PII)").unwrap();
assert_eq!(
p.stages,
vec![Stage::Taint {
label: "PII".into(),
scopes: vec![TaintScope::Session],
}]
);
let p = parse_pipeline("taint(PII, message)").unwrap();
assert_eq!(
p.stages,
vec![Stage::Taint {
label: "PII".into(),
scopes: vec![TaintScope::Message],
}]
);
let p = parse_pipeline("taint(PII, [session, message])").unwrap();
assert_eq!(
p.stages,
vec![Stage::Taint {
label: "PII".into(),
scopes: vec![TaintScope::Session, TaintScope::Message],
}]
);
}
#[test]
fn pipeline_unknown_stage_rejected() {
let err = parse_pipeline("nonsense").unwrap_err();
assert!(format!("{}", err).contains("unknown stage"));
}
#[test]
fn pipeline_omit_with_args_rejected() {
let err = parse_pipeline("omit(!perm.x)").unwrap_err();
assert!(format!("{}", err).contains("omit takes no arguments"));
}
#[test]
fn compile_route_with_args_and_result() {
let yaml = r#"
routes:
get_compensation:
args:
employee_id: "uuid"
amount: "int | 0..1M"
result:
ssn: "str | redact(!perm.view_ssn)"
employee_id: "str | mask(4)"
internal_notes: "omit"
"#;
let routes = compile_config(yaml).unwrap().routes;
let route = routes.get("get_compensation").expect("missing route");
assert_eq!(route.args.len(), 2);
assert_eq!(route.result.len(), 3);
let ssn = route.result.iter().find(|f| f.field == "ssn").unwrap();
assert_eq!(ssn.pipeline.stages.len(), 2);
assert!(matches!(
ssn.pipeline.stages[0],
Stage::Type(TypeCheck::Str)
));
assert!(matches!(
ssn.pipeline.stages[1],
Stage::Redact { condition: Some(_) }
));
let phases = route.declared_phases();
assert!(phases.contains(crate::rules::Phase::Args));
assert!(phases.contains(crate::rules::Phase::Result));
}
#[test]
fn compile_route_with_only_args_still_compiles() {
let yaml = r#"
routes:
validate_only:
args:
employee_id: "uuid"
"#;
let routes = compile_config(yaml).unwrap().routes;
assert!(routes.contains_key("validate_only"));
}
#[test]
fn compile_propagates_pipeline_parse_errors() {
let yaml = r#"
routes:
bad:
result:
x: "nonsense"
"#;
let err = compile_config(yaml).unwrap_err();
assert!(format!("{}", err).contains("unknown stage"));
}
#[test]
fn compile_captures_root_plugins_block_into_registry() {
let yaml = r#"
plugins:
- name: rate_limiter
kind: native
hooks: [tool_pre_invoke]
capabilities: [read_subject]
config:
max_requests: 100
- name: audit
kind: native
hooks: [tool_post_invoke]
routes:
get_compensation:
policy:
- "plugin(rate_limiter)"
"#;
let cfg = compile_config(yaml).unwrap();
assert_eq!(cfg.plugins.len(), 2);
let rl = cfg.plugins.get("rate_limiter").unwrap();
assert_eq!(rl.kind, "native");
assert_eq!(rl.hooks, vec!["tool_pre_invoke".to_string()]);
assert_eq!(rl.capabilities, vec!["read_subject".to_string()]);
assert!(cfg.routes.contains_key("get_compensation"));
}
#[test]
fn compile_captures_route_level_plugin_overrides() {
let yaml = r#"
plugins:
- name: rate_limiter
kind: native
hooks: [tool_pre_invoke]
config:
max_requests: 100
routes:
hot_path:
policy:
- "plugin(rate_limiter)"
plugins:
rate_limiter:
config:
max_requests: 10
on_error: ignore
"#;
let cfg = compile_config(yaml).unwrap();
let route = cfg.routes.get("hot_path").unwrap();
let ovr = route.plugin_overrides.get("rate_limiter").unwrap();
assert_eq!(ovr.on_error.as_deref(), Some("ignore"));
let cfg_yaml = ovr.config.as_ref().unwrap();
assert_eq!(
cfg_yaml["max_requests"],
serde_yaml::from_str::<serde_yaml::Value>("10").unwrap()
);
let eff = crate::plugin_decl::EffectivePlugin::resolve(
"rate_limiter",
&cfg.plugins,
&route.plugin_overrides,
)
.unwrap();
assert_eq!(eff.on_error, Some("ignore"));
assert_eq!(eff.hooks, &["tool_pre_invoke".to_string()]);
}
#[test]
fn compile_policy_block_value_parses_apl_body() {
let yaml = r#"
policy:
- "require(authenticated)"
result:
ssn: "redact(!perm.view_ssn)"
"#;
let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
let compiled =
compile_policy_block_value("global.policy.all", &value).expect("compile block");
assert_eq!(compiled.route_key, "global.policy.all");
assert_eq!(compiled.policy.len(), 1);
assert_eq!(compiled.result.len(), 1);
assert_eq!(compiled.result[0].field, "ssn");
}
#[test]
fn compile_policy_block_value_null_is_empty_route() {
let value = serde_yaml::Value::Null;
let compiled =
compile_policy_block_value("global.defaults.tool", &value).expect("compile null");
assert!(compiled.declared_phases().is_empty());
assert_eq!(compiled.route_key, "global.defaults.tool");
}
#[test]
fn compile_policy_block_value_threads_source_into_rule_paths() {
let yaml = r#"
policy:
- "require(authenticated)"
"#;
let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
let compiled = compile_policy_block_value("global.policies.hr", &value).expect("compile");
match &compiled.policy[0] {
crate::rules::Effect::When { source, .. } => {
assert_eq!(source, "global.policies.hr.policy[0]");
},
other => panic!("expected When, got {:?}", other),
}
}
#[test]
fn parse_delegate_step_with_only_plugin() {
let yaml = r#"
- delegate:
plugin: workday-oauth
"#;
let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
let entry = &value.as_sequence().unwrap()[0];
let step = parse_step(entry, "test.policy[0]").expect("parse");
let crate::step::Step::Delegate(ds) = step else {
panic!("expected Delegate, got {step:?}");
};
assert_eq!(ds.plugin_name, "workday-oauth");
assert!(ds.config_override.is_none());
assert!(ds.on_error.is_none());
assert_eq!(ds.source, "test.policy[0]");
}
#[test]
fn parse_delegate_step_with_config_and_on_error() {
let yaml = r#"
- delegate:
plugin: workday-oauth
config:
target: workday-api
permissions: [read_compensation]
on_error: deny
"#;
let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
let entry = &value.as_sequence().unwrap()[0];
let step = parse_step(entry, "test.policy[1]").expect("parse");
let crate::step::Step::Delegate(ds) = step else {
panic!("expected Delegate, got {step:?}");
};
assert_eq!(ds.plugin_name, "workday-oauth");
assert_eq!(ds.on_error.as_deref(), Some("deny"));
let cfg = ds.config_override.as_ref().expect("config_override set");
let target = cfg
.as_mapping()
.and_then(|m| m.get(serde_yaml::Value::String("target".into())))
.and_then(|v| v.as_str());
assert_eq!(target, Some("workday-api"));
}
#[test]
fn parse_delegate_step_missing_plugin_errors() {
let yaml = r#"
- delegate:
config: { target: workday-api }
"#;
let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
let entry = &value.as_sequence().unwrap()[0];
let err = parse_step(entry, "test.policy[0]").expect_err("missing plugin");
let msg = format!("{err}");
assert!(msg.contains("requires `plugin:"), "got: {msg}");
}
#[test]
fn parse_delegate_step_empty_plugin_errors() {
let yaml = r#"
- delegate:
plugin: ""
"#;
let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
let entry = &value.as_sequence().unwrap()[0];
let err = parse_step(entry, "test.policy[0]").expect_err("empty plugin");
let msg = format!("{err}");
assert!(msg.contains("cannot be empty"), "got: {msg}");
}
#[test]
fn parse_delegate_step_non_string_on_error_errors() {
let yaml = r#"
- delegate:
plugin: workday-oauth
on_error: 42
"#;
let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
let entry = &value.as_sequence().unwrap()[0];
let err = parse_step(entry, "test.policy[0]").expect_err("non-string on_error");
let msg = format!("{err}");
assert!(msg.contains("on_error"), "got: {msg}");
}
#[test]
fn parse_delegate_step_non_map_body_errors() {
let yaml = r#"
- delegate: workday-oauth
"#;
let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
let entry = &value.as_sequence().unwrap()[0];
let err = parse_step(entry, "test.policy[0]").expect_err("non-map delegate body");
let msg = format!("{err}");
assert!(msg.contains("must be a map"), "got: {msg}");
}
#[test]
fn parse_delegate_string_bare_plugin_name() {
let yaml = r#"- "delegate(workday-oauth)""#;
let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
let entry = &value.as_sequence().unwrap()[0];
let step = parse_step(entry, "test.policy[0]").expect("parse");
let crate::step::Step::Delegate(ds) = step else {
panic!("expected Delegate, got {step:?}");
};
assert_eq!(ds.plugin_name, "workday-oauth");
assert!(ds.config_override.is_none());
assert!(ds.on_error.is_none());
assert_eq!(ds.source, "test.policy[0]");
}
#[test]
fn parse_delegate_string_with_string_kwargs() {
let yaml =
r#"- "delegate(workday-oauth, target: workday-api, audience: https://workday.com)""#;
let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
let entry = &value.as_sequence().unwrap()[0];
let step = parse_step(entry, "test.policy[0]").expect("parse");
let crate::step::Step::Delegate(ds) = step else {
panic!("expected Delegate, got {step:?}");
};
assert_eq!(ds.plugin_name, "workday-oauth");
let cfg = ds.config_override.as_ref().unwrap().as_mapping().unwrap();
assert_eq!(
cfg.get(serde_yaml::Value::String("target".into()))
.and_then(|v| v.as_str()),
Some("workday-api"),
);
assert_eq!(
cfg.get(serde_yaml::Value::String("audience".into()))
.and_then(|v| v.as_str()),
Some("https://workday.com"),
);
}
#[test]
fn parse_delegate_string_with_list_kwarg() {
let yaml = r#"- "delegate(workday-oauth, permissions: [read_compensation, write_notes])""#;
let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
let entry = &value.as_sequence().unwrap()[0];
let step = parse_step(entry, "test.policy[0]").expect("parse");
let crate::step::Step::Delegate(ds) = step else {
panic!("expected Delegate");
};
let cfg = ds.config_override.as_ref().unwrap().as_mapping().unwrap();
let perms = cfg
.get(serde_yaml::Value::String("permissions".into()))
.and_then(|v| v.as_sequence())
.expect("permissions sequence");
let names: Vec<&str> = perms.iter().filter_map(|v| v.as_str()).collect();
assert_eq!(names, vec!["read_compensation", "write_notes"]);
}
#[test]
fn parse_delegate_string_on_error_pulled_out() {
let yaml = r#"- "delegate(workday-oauth, target: workday-api, on_error: continue)""#;
let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
let entry = &value.as_sequence().unwrap()[0];
let step = parse_step(entry, "test.policy[0]").expect("parse");
let crate::step::Step::Delegate(ds) = step else {
panic!("expected Delegate");
};
assert_eq!(ds.on_error.as_deref(), Some("continue"));
let cfg = ds.config_override.as_ref().unwrap().as_mapping().unwrap();
assert!(
cfg.get(serde_yaml::Value::String("on_error".into()))
.is_none(),
"on_error must not appear in config_override"
);
}
#[test]
fn parse_delegate_string_quoted_plugin_name() {
let yaml = r#"- 'delegate("workday-oauth")'"#;
let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
let entry = &value.as_sequence().unwrap()[0];
let step = parse_step(entry, "test.policy[0]").expect("parse");
let crate::step::Step::Delegate(ds) = step else {
panic!("expected Delegate");
};
assert_eq!(ds.plugin_name, "workday-oauth");
}
#[test]
fn parse_delegate_string_quoted_value_preserves_internal_commas() {
let yaml =
r#"- 'delegate(workday-oauth, audience: "https://workday.com,backup.workday.com")'"#;
let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
let entry = &value.as_sequence().unwrap()[0];
let step = parse_step(entry, "test.policy[0]").expect("parse");
let crate::step::Step::Delegate(ds) = step else {
panic!("expected Delegate");
};
let cfg = ds.config_override.as_ref().unwrap().as_mapping().unwrap();
assert_eq!(
cfg.get(serde_yaml::Value::String("audience".into()))
.and_then(|v| v.as_str()),
Some("https://workday.com,backup.workday.com"),
);
}
#[test]
fn parse_delegate_string_empty_args_errors() {
let yaml = r#"- "delegate()""#;
let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
let entry = &value.as_sequence().unwrap()[0];
let err = parse_step(entry, "test.policy[0]").expect_err("empty args");
let msg = format!("{err}");
assert!(msg.contains("plugin name"), "got: {msg}");
}
#[test]
fn parse_delegate_string_plugin_kwarg_rejected() {
let yaml = r#"- "delegate(workday-oauth, plugin: other-thing)""#;
let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
let entry = &value.as_sequence().unwrap()[0];
let err = parse_step(entry, "test.policy[0]").expect_err("plugin kwarg");
let msg = format!("{err}");
assert!(msg.contains("positional"), "got: {msg}");
}
#[test]
fn parse_delegate_string_kwarg_missing_colon_errors() {
let yaml = r#"- "delegate(workday-oauth, target workday-api)""#;
let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
let entry = &value.as_sequence().unwrap()[0];
let err = parse_step(entry, "test.policy[0]").expect_err("missing colon");
let msg = format!("{err}");
assert!(msg.contains("key: value"), "got: {msg}");
}
#[test]
fn parse_delegate_string_unbalanced_brackets_errors() {
let yaml = r#"- "delegate(workday-oauth, permissions: [read_compensation)""#;
let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
let entry = &value.as_sequence().unwrap()[0];
let err = parse_step(entry, "test.policy[0]").expect_err("unbalanced");
let msg = format!("{err}");
assert!(
msg.contains("unmatched") || msg.contains("unbalanced"),
"got: {msg}"
);
}
#[test]
fn compile_route_mixed_string_and_map_delegate_forms() {
let yaml = r#"
routes:
get_compensation:
policy:
- "require(role.hr)"
- "delegate(workday-oauth, target: workday-api, permissions: [read_compensation])"
- delegate:
plugin: audit-receipt
on_error: continue
config:
mode: trace
"#;
let cfg = compile_config(yaml).expect("compile");
let route = cfg.routes.get("get_compensation").expect("route");
assert_eq!(route.policy.len(), 3);
let crate::rules::Effect::Delegate(s1) = &route.policy[1] else {
panic!("expected Delegate at policy[1]");
};
assert_eq!(s1.plugin_name, "workday-oauth");
assert!(s1.on_error.is_none());
let crate::rules::Effect::Delegate(s2) = &route.policy[2] else {
panic!("expected Delegate at policy[2]");
};
assert_eq!(s2.plugin_name, "audit-receipt");
assert_eq!(s2.on_error.as_deref(), Some("continue"));
}
#[test]
fn compile_route_with_delegate_in_policy_and_post_policy() {
let yaml = r#"
routes:
get_compensation:
policy:
- "require(role.hr)"
- delegate:
plugin: workday-oauth
config:
target: workday-api
permissions: [read_compensation]
- "require(authenticated)"
post_policy:
- delegate:
plugin: audit-biscuit
on_error: continue
"#;
let cfg = compile_config(yaml).expect("compile");
let route = cfg.routes.get("get_compensation").expect("route present");
assert_eq!(route.policy.len(), 3);
let crate::rules::Effect::Delegate(ds) = &route.policy[1] else {
panic!("expected Delegate at policy[1], got {:?}", route.policy[1]);
};
assert_eq!(ds.plugin_name, "workday-oauth");
assert_eq!(ds.source, "get_compensation.policy[1]");
let crate::rules::Effect::Delegate(post_ds) = &route.post_policy[0] else {
panic!("expected Delegate at post_policy[0]");
};
assert_eq!(post_ds.plugin_name, "audit-biscuit");
assert_eq!(post_ds.on_error.as_deref(), Some("continue"));
assert_eq!(post_ds.source, "get_compensation.post_policy[0]");
}
#[test]
fn parse_pipeline_rejects_validate_stage_at_compile_time() {
let err = parse_pipeline("str | validate(ssn_format) | mask(4)")
.expect_err("validate(name) should fail to parse");
let msg = format!("{err}");
assert!(
msg.contains("not implemented"),
"diagnostic should explain that validate is unimplemented: {msg}",
);
assert!(
msg.contains("regex") && msg.contains("plugin"),
"diagnostic should suggest regex(...) and plugin(...): {msg}",
);
assert!(
msg.contains("ssn_format"),
"diagnostic should echo the rejected validator name: {msg}",
);
}
#[test]
fn parse_pipeline_does_not_reject_other_stages() {
let p = parse_pipeline("str | len(..100) | regex(\"^[A-Z]+$\") | mask(4)")
.expect("non-validate pipeline parses");
assert_eq!(p.stages.len(), 4);
}
}