1use std::collections::HashMap;
26
27use serde::Deserialize;
28use thiserror::Error;
29
30use crate::pipeline::{FieldRule, Pipeline, ScanKind, Stage, TaintScope, TypeCheck};
31use crate::plugin_decl::{PluginDeclaration, PluginOverride, PluginRegistry};
32use crate::rules::{CompareOp, CompiledRoute, Condition, Effect, Expression, Literal, Rule};
33use crate::step::{DelegateStep, ElicitKind, ElicitStep, PdpCall, PdpDialect, Step};
34
35#[derive(Debug, Error)]
40pub enum ParseError {
41 #[error("YAML parse error: {0}")]
42 Yaml(#[from] serde_yaml::Error),
43
44 #[error("rule '{rule}': {msg}")]
45 Rule { rule: String, msg: String },
46
47 #[error("unsupported step `{kind}` in rule '{rule}' — defer to step 5b")]
48 UnsupportedStep { rule: String, kind: String },
49
50 #[error("predicate '{predicate}': {msg}")]
51 Predicate { predicate: String, msg: String },
52
53 #[error("in `{location}`: config field `{old}` was renamed to `{new}` — update your config")]
54 RenamedField {
55 location: String,
56 old: String,
57 new: String,
58 },
59
60 #[error(
61 "in `{location}`: `{phase}` is declared both nested under `authorization:` and flat on \
62 the section — use one form, not both (declaring both runs the effects twice)"
63 )]
64 ConflictingAuthorizationForms { location: String, phase: String },
65}
66
67#[derive(Debug, Clone, PartialEq)]
72enum Tok {
73 Ident(String), StringLit(String),
75 IntLit(i64),
76 FloatLit(f64),
77 BoolLit(bool),
78 Eq, NotEq, Gt, GtEq, Lt, LtEq, And, Or, Not, LParen,
88 RParen,
89 Comma,
90 Contains, Require, Exists, In, }
95
96struct Lexer<'a> {
97 src: &'a str,
98 bytes: &'a [u8],
99 pos: usize,
100}
101
102impl<'a> Lexer<'a> {
103 fn new(src: &'a str) -> Self {
104 Self {
105 src,
106 bytes: src.as_bytes(),
107 pos: 0,
108 }
109 }
110
111 fn peek(&self) -> Option<u8> {
112 self.bytes.get(self.pos).copied()
113 }
114
115 fn bump(&mut self) -> Option<u8> {
116 let b = self.peek()?;
117 self.pos += 1;
118 Some(b)
119 }
120
121 fn skip_ws(&mut self) {
122 while let Some(b) = self.peek() {
123 if b.is_ascii_whitespace() {
124 self.pos += 1;
125 } else {
126 break;
127 }
128 }
129 }
130
131 fn tokenize_all(&mut self) -> Result<Vec<Tok>, ParseError> {
132 let mut out = Vec::new();
133 loop {
134 self.skip_ws();
135 let Some(b) = self.peek() else {
136 return Ok(out);
137 };
138
139 let tok = match b {
140 b'(' => {
141 self.pos += 1;
142 Tok::LParen
143 },
144 b')' => {
145 self.pos += 1;
146 Tok::RParen
147 },
148 b',' => {
149 self.pos += 1;
150 Tok::Comma
151 },
152 b'&' => {
153 self.pos += 1;
154 Tok::And
155 },
156 b'|' => {
157 self.pos += 1;
158 Tok::Or
159 },
160 b'=' => {
161 self.pos += 1;
162 if self.peek() == Some(b'=') {
163 self.pos += 1;
164 Tok::Eq
165 } else {
166 return Err(self.err("expected `==`, saw `=`"));
167 }
168 },
169 b'!' => {
170 self.pos += 1;
171 if self.peek() == Some(b'=') {
172 self.pos += 1;
173 Tok::NotEq
174 } else {
175 Tok::Not
176 }
177 },
178 b'>' => {
179 self.pos += 1;
180 if self.peek() == Some(b'=') {
181 self.pos += 1;
182 Tok::GtEq
183 } else {
184 Tok::Gt
185 }
186 },
187 b'<' => {
188 self.pos += 1;
189 if self.peek() == Some(b'=') {
190 self.pos += 1;
191 Tok::LtEq
192 } else {
193 Tok::Lt
194 }
195 },
196 b'"' | b'\'' => self.lex_string(b)?,
197 b'-' | b'0'..=b'9' => self.lex_number()?,
198 b if is_ident_start(b) => self.lex_ident_or_keyword(),
199 _ => return Err(self.err(&format!("unexpected char `{}`", b as char))),
200 };
201 out.push(tok);
202 }
203 }
204
205 fn lex_string(&mut self, quote: u8) -> Result<Tok, ParseError> {
206 self.bump(); let start = self.pos;
208 while let Some(b) = self.peek() {
209 if b == quote {
210 break;
211 }
212 self.pos += 1;
213 }
214 if self.peek() != Some(quote) {
215 return Err(self.err("unterminated string literal"));
216 }
217 let s = std::str::from_utf8(&self.bytes[start..self.pos])
218 .map_err(|_| self.err("non-utf8 in string literal"))?
219 .to_string();
220 self.bump(); Ok(Tok::StringLit(s))
222 }
223
224 fn lex_number(&mut self) -> Result<Tok, ParseError> {
225 let start = self.pos;
226 if self.peek() == Some(b'-') {
227 self.pos += 1;
228 }
229 while let Some(b) = self.peek() {
230 if b.is_ascii_digit() {
231 self.pos += 1;
232 } else {
233 break;
234 }
235 }
236 let mut is_float = false;
237 if self.peek() == Some(b'.') {
238 is_float = true;
239 self.pos += 1;
240 while let Some(b) = self.peek() {
241 if b.is_ascii_digit() {
242 self.pos += 1;
243 } else {
244 break;
245 }
246 }
247 }
248 let text = &self.src[start..self.pos];
249 if is_float {
250 text.parse::<f64>()
251 .map(Tok::FloatLit)
252 .map_err(|_| self.err(&format!("bad float `{}`", text)))
253 } else {
254 text.parse::<i64>()
255 .map(Tok::IntLit)
256 .map_err(|_| self.err(&format!("bad int `{}`", text)))
257 }
258 }
259
260 fn lex_ident_or_keyword(&mut self) -> Tok {
261 let start = self.pos;
262 while let Some(b) = self.peek() {
263 if is_ident_cont(b) {
264 self.pos += 1;
265 } else {
266 break;
267 }
268 }
269 let s = &self.src[start..self.pos];
270 match s {
271 "true" => Tok::BoolLit(true),
272 "false" => Tok::BoolLit(false),
273 "contains" => Tok::Contains,
274 "require" => Tok::Require,
275 "exists" => Tok::Exists,
276 "in" => Tok::In,
277 _ => Tok::Ident(s.to_string()),
281 }
282 }
283
284 fn err(&self, msg: &str) -> ParseError {
285 ParseError::Predicate {
286 predicate: self.src.to_string(),
287 msg: format!("at byte {}: {}", self.pos, msg),
288 }
289 }
290}
291
292fn is_ident_start(b: u8) -> bool {
293 b.is_ascii_alphabetic() || b == b'_'
294}
295
296fn is_ident_cont(b: u8) -> bool {
297 b.is_ascii_alphanumeric() || b == b'_' || b == b'.'
298}
299
300struct PredParser<'a> {
305 src: &'a str,
306 toks: Vec<Tok>,
307 pos: usize,
308}
309
310impl<'a> PredParser<'a> {
311 fn parse(src: &'a str) -> Result<Expression, ParseError> {
312 let toks = Lexer::new(src).tokenize_all()?;
313 let mut p = Self { src, toks, pos: 0 };
314 let expr = p.parse_or()?;
315 if p.pos < p.toks.len() {
316 return Err(p.err(&format!(
317 "trailing tokens after expression: {:?}",
318 &p.toks[p.pos..]
319 )));
320 }
321 Ok(expr)
322 }
323
324 fn peek(&self) -> Option<&Tok> {
325 self.toks.get(self.pos)
326 }
327 fn bump(&mut self) -> Option<Tok> {
328 let t = self.toks.get(self.pos).cloned()?;
329 self.pos += 1;
330 Some(t)
331 }
332 fn err(&self, msg: &str) -> ParseError {
333 ParseError::Predicate {
334 predicate: self.src.to_string(),
335 msg: msg.to_string(),
336 }
337 }
338
339 fn parse_or(&mut self) -> Result<Expression, ParseError> {
340 let mut parts = vec![self.parse_and()?];
341 while matches!(self.peek(), Some(Tok::Or)) {
342 self.bump();
343 parts.push(self.parse_and()?);
344 }
345 Ok(if parts.len() == 1 {
346 parts.pop().unwrap()
347 } else {
348 Expression::Or(parts)
349 })
350 }
351
352 fn parse_and(&mut self) -> Result<Expression, ParseError> {
353 let mut parts = vec![self.parse_unary()?];
354 while matches!(self.peek(), Some(Tok::And)) {
355 self.bump();
356 parts.push(self.parse_unary()?);
357 }
358 Ok(if parts.len() == 1 {
359 parts.pop().unwrap()
360 } else {
361 Expression::And(parts)
362 })
363 }
364
365 fn parse_unary(&mut self) -> Result<Expression, ParseError> {
366 if matches!(self.peek(), Some(Tok::Not)) {
367 self.bump();
368 let inner = self.parse_unary()?;
369 return Ok(Expression::Not(Box::new(inner)));
370 }
371 self.parse_atom()
372 }
373
374 fn parse_atom(&mut self) -> Result<Expression, ParseError> {
375 match self.peek() {
376 Some(Tok::LParen) => {
377 self.bump();
378 let inner = self.parse_or()?;
379 match self.bump() {
380 Some(Tok::RParen) => Ok(inner),
381 _ => Err(self.err("expected `)`")),
382 }
383 },
384 Some(Tok::Require) => Err(self.err(
388 "`require(...)` is a rule-level shorthand, not a sub-predicate \
389 — use `&` / `|` / `!` over bare identifiers instead",
390 )),
391 Some(Tok::Exists) => self.parse_exists(),
392 Some(Tok::Ident(_)) => self.parse_identifier_predicate(),
393 other => Err(self.err(&format!("expected atom, got {:?}", other))),
394 }
395 }
396
397 fn parse_exists(&mut self) -> Result<Expression, ParseError> {
400 self.bump(); match self.bump() {
402 Some(Tok::LParen) => {},
403 _ => return Err(self.err("expected `(` after `exists`")),
404 }
405 let key = match self.bump() {
406 Some(Tok::Ident(s)) => s,
407 other => {
408 return Err(self.err(&format!(
409 "exists(...) expects an attribute key, got {:?}",
410 other,
411 )))
412 },
413 };
414 match self.bump() {
415 Some(Tok::RParen) => {},
416 other => {
417 return Err(self.err(&format!(
418 "expected `)` after exists() argument, got {:?}",
419 other,
420 )))
421 },
422 }
423 Ok(Expression::Condition(Condition::Exists { key }))
424 }
425
426 fn parse_identifier_predicate(&mut self) -> Result<Expression, ParseError> {
433 let key = match self.bump() {
434 Some(Tok::Ident(s)) => s,
435 _ => unreachable!("parse_atom dispatched here"),
436 };
437
438 if matches!(self.peek(), Some(Tok::In)) {
440 self.bump();
441 return self.finish_in_set(key, false);
442 }
443 if let Some(Tok::Ident(maybe_not)) = self.peek() {
447 if maybe_not == "not" {
448 let saved_pos = self.pos;
449 self.bump(); if matches!(self.peek(), Some(Tok::In)) {
451 self.bump();
452 return self.finish_in_set(key, true);
453 }
454 self.pos = saved_pos;
457 }
458 }
459
460 let op = match self.peek() {
461 Some(Tok::Eq) => Some(CompareOp::Eq),
462 Some(Tok::NotEq) => Some(CompareOp::NotEq),
463 Some(Tok::Gt) => Some(CompareOp::Gt),
464 Some(Tok::GtEq) => Some(CompareOp::GtEq),
465 Some(Tok::Lt) => Some(CompareOp::Lt),
466 Some(Tok::LtEq) => Some(CompareOp::LtEq),
467 Some(Tok::Contains) => Some(CompareOp::Contains),
468 _ => None,
469 };
470
471 let Some(op) = op else {
472 return Ok(Expression::Condition(Condition::IsTrue { key }));
474 };
475 self.bump();
476
477 let value = match self.bump() {
478 Some(Tok::StringLit(s)) => Literal::String(s),
479 Some(Tok::IntLit(i)) => Literal::Int(i),
480 Some(Tok::FloatLit(f)) => Literal::Float(f),
481 Some(Tok::BoolLit(b)) => Literal::Bool(b),
482 Some(Tok::Ident(_)) => {
483 return Err(self.err(
484 "RHS-as-identifier on comparison operators not supported — \
485 for set membership use `value_key in set_key`",
486 ));
487 },
488 other => return Err(self.err(&format!("expected literal RHS, got {:?}", other))),
489 };
490
491 Ok(Expression::Condition(Condition::Comparison {
492 key,
493 op,
494 value,
495 }))
496 }
497
498 fn finish_in_set(&mut self, value_key: String, negate: bool) -> Result<Expression, ParseError> {
499 let set_key = match self.bump() {
500 Some(Tok::Ident(s)) => s,
501 other => {
502 return Err(self.err(&format!(
503 "expected set-attribute identifier after `{}in`, got {:?}",
504 if negate { "not " } else { "" },
505 other,
506 )))
507 },
508 };
509 Ok(Expression::Condition(Condition::InSet {
510 value_key,
511 set_key,
512 negate,
513 }))
514 }
515}
516
517pub fn parse_predicate(src: &str) -> Result<Expression, ParseError> {
519 PredParser::parse(src.trim())
520}
521
522pub fn parse_rule(line: &str, source: &str) -> Result<Rule, ParseError> {
541 let trimmed = line.trim();
542
543 if is_require_call(trimmed) {
547 let condition = parse_require_rule(trimmed)?;
548 return Ok(Rule::single(
549 condition,
550 Effect::Deny {
551 reason: None,
552 code: None,
553 },
554 source,
555 ));
556 }
557
558 if let Some(kind) = detect_step_kind(trimmed) {
561 return Err(ParseError::UnsupportedStep {
562 rule: trimmed.to_string(),
563 kind: format!("{} (use parse_step for step kinds)", kind),
564 });
565 }
566
567 let (predicate_str, effects) = match split_predicate_action(trimmed) {
568 Some((p, a)) => (p, parse_action(a, trimmed)?),
569 None => {
570 if let Some(effects) = try_bare_action(trimmed) {
572 return Ok(Rule {
573 condition: Expression::Always,
574 effects,
575 source: source.to_string(),
576 });
577 }
578 if let Some(deny) = try_parse_deny_call(trimmed, trimmed)? {
585 return Ok(Rule {
586 condition: Expression::Always,
587 effects: vec![deny],
588 source: source.to_string(),
589 });
590 }
591 (
593 trimmed,
594 vec![Effect::Deny {
595 reason: None,
596 code: None,
597 }],
598 )
599 },
600 };
601
602 let condition = parse_predicate(predicate_str).map_err(|e| ParseError::Rule {
603 rule: trimmed.to_string(),
604 msg: format!("{}", e),
605 })?;
606
607 Ok(Rule {
608 condition,
609 effects,
610 source: source.to_string(),
611 })
612}
613
614fn is_require_call(s: &str) -> bool {
615 s.trim_start().starts_with("require(")
616}
617
618fn parse_require_rule(line: &str) -> Result<Expression, ParseError> {
627 let toks = Lexer::new(line).tokenize_all()?;
628 let mut iter = toks.into_iter().peekable();
629
630 let bad = |msg: &str| ParseError::Rule {
631 rule: line.to_string(),
632 msg: msg.to_string(),
633 };
634
635 match iter.next() {
636 Some(Tok::Require) => {},
637 _ => return Err(bad("expected `require`")),
638 }
639 match iter.next() {
640 Some(Tok::LParen) => {},
641 _ => return Err(bad("expected `(` after `require`")),
642 }
643
644 let mut keys = Vec::new();
645 let mut sep: Option<Tok> = None;
646
647 match iter.next() {
648 Some(Tok::Ident(s)) => keys.push(s),
649 _ => return Err(bad("expected identifier inside `require(...)`")),
650 }
651
652 loop {
653 match iter.next() {
654 Some(Tok::RParen) => break,
655 Some(t @ Tok::Comma) | Some(t @ Tok::Or) => {
656 match &sep {
657 None => sep = Some(t),
658 Some(prev) if std::mem::discriminant(prev) == std::mem::discriminant(&t) => {},
659 _ => {
660 return Err(bad(
661 "require(...) cannot mix `,` (AND) and `|` (OR) — use one or the other",
662 ))
663 },
664 }
665 match iter.next() {
666 Some(Tok::Ident(s)) => keys.push(s),
667 _ => return Err(bad("expected identifier after `,` or `|` in require(...)")),
668 }
669 },
670 Some(other) => {
671 return Err(bad(&format!(
672 "expected `,`, `|`, or `)` in require(...), got {:?}",
673 other,
674 )))
675 },
676 None => return Err(bad("unexpected end of require(...) — missing `)`")),
677 }
678 }
679
680 if iter.peek().is_some() {
681 return Err(bad(
682 "trailing tokens after `require(...)` — require is a complete rule",
683 ));
684 }
685
686 let falses: Vec<Expression> = keys
687 .into_iter()
688 .map(|k| Expression::Condition(Condition::IsFalse { key: k }))
689 .collect();
690 if falses.len() == 1 {
691 return Ok(falses.into_iter().next().unwrap());
692 }
693 Ok(match sep {
694 Some(Tok::Or) => Expression::And(falses), _ => Expression::Or(falses), })
697}
698
699fn detect_step_kind(s: &str) -> Option<&'static str> {
701 let s = s.trim_start();
702 for prefix in [
703 "taint(",
704 "plugin(",
705 "run(",
706 "cedar:",
707 "opa(",
708 "authzen(",
709 "nemo(",
710 "cel:",
711 "sequential:",
712 "parallel:",
713 ] {
714 if s.starts_with(prefix) {
715 return Some(prefix.trim_end_matches('(').trim_end_matches(':'));
716 }
717 }
718 None
719}
720
721fn split_predicate_action(s: &str) -> Option<(&str, &str)> {
726 let bytes = s.as_bytes();
727 let mut depth: i32 = 0;
728 let mut in_quote: Option<u8> = None;
729 let mut last_colon: Option<usize> = None;
730 for (i, &b) in bytes.iter().enumerate() {
731 match (in_quote, b) {
732 (Some(q), c) if c == q => in_quote = None,
733 (Some(_), _) => {},
734 (None, b'"') | (None, b'\'') => in_quote = Some(b),
735 (None, b'(') => depth += 1,
736 (None, b')') => depth -= 1,
737 (None, b':') if depth == 0 => last_colon = Some(i),
738 _ => {},
739 }
740 }
741 last_colon.map(|i| (s[..i].trim(), s[i + 1..].trim()))
742}
743
744fn parse_action(s: &str, rule: &str) -> Result<Vec<Effect>, ParseError> {
757 if let Some(effect) = try_bare_action(s) {
758 return Ok(effect);
759 }
760 if let Some(deny) = try_parse_deny_call(s.trim(), rule)? {
761 return Ok(vec![deny]);
762 }
763 Err(ParseError::Rule {
764 rule: rule.to_string(),
765 msg: format!(
766 "unsupported action `{}` — recognized: `deny`, `deny('reason')`, `deny('reason', 'code')`, `allow`",
767 s.trim()
768 ),
769 })
770}
771
772fn try_bare_action(s: &str) -> Option<Vec<Effect>> {
773 match s.trim() {
774 "deny" => Some(vec![Effect::Deny {
775 reason: None,
776 code: None,
777 }]),
778 "allow" => Some(vec![Effect::Allow]),
779 _ => None,
780 }
781}
782
783fn try_parse_deny_call(s: &str, rule: &str) -> Result<Option<Effect>, ParseError> {
787 if !s.starts_with("deny(") {
788 return Ok(None);
789 }
790 let inside = extract_call_args(s, "deny").ok_or_else(|| ParseError::Rule {
791 rule: rule.to_string(),
792 msg: "malformed `deny(...)`".into(),
793 })?;
794 let parts = split_top_level_commas(&inside).map_err(|e| ParseError::Rule {
797 rule: rule.to_string(),
798 msg: format!("deny(...): {}", e),
799 })?;
800 let mut iter = parts.into_iter();
801 let reason = match iter.next() {
802 Some(p) => Some(strip_string_literal(p.trim(), rule)?),
803 None => None,
804 };
805 let code = match iter.next() {
806 Some(p) => Some(strip_string_literal(p.trim(), rule)?),
807 None => None,
808 };
809 if iter.next().is_some() {
810 return Err(ParseError::Rule {
811 rule: rule.to_string(),
812 msg: "deny(...) takes at most two args: deny('reason', 'code')".into(),
813 });
814 }
815 Ok(Some(Effect::Deny { reason, code }))
816}
817
818fn strip_string_literal(s: &str, rule: &str) -> Result<String, ParseError> {
822 let s = s.trim();
823 if (s.starts_with('\'') && s.ends_with('\'') && s.len() >= 2)
824 || (s.starts_with('"') && s.ends_with('"') && s.len() >= 2)
825 {
826 Ok(s[1..s.len() - 1].to_string())
827 } else {
828 Err(ParseError::Rule {
829 rule: rule.to_string(),
830 msg: format!("expected a quoted string, got `{}`", s),
831 })
832 }
833}
834
835pub fn parse_step(value: &serde_yaml::Value, source: &str) -> Result<Step, ParseError> {
851 match value {
852 serde_yaml::Value::String(s) => parse_step_string(s, source),
853 serde_yaml::Value::Mapping(m) => parse_step_map(m, source),
854 other => Err(ParseError::Rule {
855 rule: format!("{:?}", other),
856 msg: "step must be a string or a single-key map".into(),
857 }),
858 }
859}
860
861fn parse_step_string(line: &str, source: &str) -> Result<Step, ParseError> {
862 let trimmed = line.trim();
863
864 if trimmed.starts_with("taint(") {
867 let inside = extract_call_args(trimmed, "taint").ok_or_else(|| ParseError::Rule {
868 rule: trimmed.to_string(),
869 msg: "malformed `taint(...)`".into(),
870 })?;
871 let taint_stage = parse_taint(&inside, trimmed)?;
872 if let Stage::Taint { label, scopes } = taint_stage {
874 return Ok(Step::Taint { label, scopes });
875 }
876 unreachable!("parse_taint always returns Stage::Taint");
877 }
878
879 let plugin_verb = if trimmed.starts_with("plugin(") {
882 Some("plugin")
883 } else if trimmed.starts_with("run(") {
884 Some("run")
885 } else {
886 None
887 };
888 if let Some(verb) = plugin_verb {
889 let inside = extract_call_args(trimmed, verb).ok_or_else(|| ParseError::Rule {
890 rule: trimmed.to_string(),
891 msg: format!("malformed `{verb}(...)`"),
892 })?;
893 let name = inside.trim();
894 if name.is_empty() {
895 return Err(ParseError::Rule {
896 rule: trimmed.to_string(),
897 msg: format!("`{verb}(...)`: plugin name must not be empty"),
898 });
899 }
900 return Ok(Step::Plugin {
901 name: name.to_string(),
902 });
903 }
904
905 if trimmed.starts_with("delegate(") {
912 let inside = extract_call_args(trimmed, "delegate").ok_or_else(|| ParseError::Rule {
913 rule: trimmed.to_string(),
914 msg: "malformed `delegate(...)`".into(),
915 })?;
916 let parsed = parse_delegate_call_args(&inside, source)?;
917 return Ok(Step::Delegate(DelegateStep {
918 plugin_name: parsed.plugin_name,
919 config_override: parsed.config_override,
920 on_error: parsed.on_error,
921 source: source.to_string(),
922 }));
923 }
924
925 for (verb, kind) in ELICIT_VERBS {
932 if trimmed.starts_with(&format!("{verb}(")) {
933 let inside = extract_call_args(trimmed, verb).ok_or_else(|| ParseError::Rule {
934 rule: trimmed.to_string(),
935 msg: format!("malformed `{verb}(...)`"),
936 })?;
937 let parsed = parse_elicit_call_args(verb, &inside, source)?;
938 return Ok(Step::Elicit(ElicitStep {
939 kind: *kind,
940 plugin_name: parsed.plugin_name,
941 channel: parsed.channel,
942 from: parsed.from,
943 purpose: parsed.purpose,
944 scope: parsed.scope,
945 timeout: parsed.timeout,
946 config_override: parsed.config_override,
947 on_error: parsed.on_error,
948 source: source.to_string(),
949 }));
950 }
951 }
952
953 let rule = parse_rule(trimmed, source)?;
955 Ok(Step::Rule(rule))
956}
957
958struct ParsedDelegateCall {
962 plugin_name: String,
963 config_override: Option<serde_yaml::Value>,
964 on_error: Option<String>,
965}
966
967fn parse_delegate_call_args(inside: &str, source: &str) -> Result<ParsedDelegateCall, ParseError> {
985 let parts = split_top_level_commas(inside).map_err(|msg| ParseError::Rule {
986 rule: format!("delegate({inside})"),
987 msg: format!("{source}: {msg}"),
988 })?;
989 let mut parts_iter = parts.into_iter();
990
991 let plugin_name = parts_iter
992 .next()
993 .map(|s| s.trim().to_string())
994 .filter(|s| !s.is_empty())
995 .ok_or_else(|| ParseError::Rule {
996 rule: format!("delegate({inside})"),
997 msg: format!(
998 "{source}: `delegate(...)` requires a plugin name as the first \
999 positional argument"
1000 ),
1001 })?;
1002 let plugin_name = strip_wrapping_quotes(&plugin_name).to_string();
1004 if plugin_name.is_empty() {
1005 return Err(ParseError::Rule {
1006 rule: format!("delegate({inside})"),
1007 msg: format!("{source}: `delegate(...)` plugin name cannot be empty"),
1008 });
1009 }
1010
1011 let mut on_error: Option<String> = None;
1012 let mut config_map = serde_yaml::Mapping::new();
1013
1014 for raw_kwarg in parts_iter {
1015 let kwarg = raw_kwarg.trim();
1016 if kwarg.is_empty() {
1017 continue;
1018 }
1019 let (key, value_str) = kwarg.split_once(':').ok_or_else(|| ParseError::Rule {
1020 rule: kwarg.to_string(),
1021 msg: format!(
1022 "{source}: `delegate(...)` kwarg `{kwarg}` must be `key: value` \
1023 (use the map form for richer config)"
1024 ),
1025 })?;
1026 let key = key.trim();
1027 let value_str = value_str.trim();
1028 if key.is_empty() {
1029 return Err(ParseError::Rule {
1030 rule: kwarg.to_string(),
1031 msg: format!("{source}: `delegate(...)` kwarg has empty key"),
1032 });
1033 }
1034 if key == "on_error" {
1035 let val = parse_delegate_value(value_str).map_err(|msg| ParseError::Rule {
1036 rule: kwarg.to_string(),
1037 msg: format!("{source}: on_error: {msg}"),
1038 })?;
1039 on_error = Some(
1040 val.as_str()
1041 .ok_or_else(|| ParseError::Rule {
1042 rule: kwarg.to_string(),
1043 msg: format!("{source}: `on_error` must be a string"),
1044 })?
1045 .to_string(),
1046 );
1047 continue;
1048 }
1049 if key == "plugin" {
1052 return Err(ParseError::Rule {
1053 rule: kwarg.to_string(),
1054 msg: format!(
1055 "{source}: `plugin` is set as the first positional argument \
1056 of `delegate(...)`; don't pass it as a kwarg too"
1057 ),
1058 });
1059 }
1060 let value = parse_delegate_value(value_str).map_err(|msg| ParseError::Rule {
1061 rule: kwarg.to_string(),
1062 msg: format!("{source}: `{key}`: {msg}"),
1063 })?;
1064 config_map.insert(serde_yaml::Value::String(key.to_string()), value);
1065 }
1066
1067 let config_override = if config_map.is_empty() {
1068 None
1069 } else {
1070 Some(serde_yaml::Value::Mapping(config_map))
1071 };
1072
1073 Ok(ParsedDelegateCall {
1074 plugin_name,
1075 config_override,
1076 on_error,
1077 })
1078}
1079
1080const ELICIT_VERBS: &[(&str, ElicitKind)] = &[
1084 ("require_approval", ElicitKind::Approval),
1085 ("confirm", ElicitKind::Confirm),
1086 ("require_step_up", ElicitKind::StepUp),
1087 ("require_attestation", ElicitKind::Attestation),
1088 ("request_info", ElicitKind::Info),
1089 ("require_review", ElicitKind::Review),
1090];
1091
1092struct ParsedElicitCall {
1096 plugin_name: String,
1097 from: String,
1098 channel: Option<String>,
1099 purpose: Option<String>,
1100 scope: Option<String>,
1101 timeout: Option<String>,
1102 on_error: Option<String>,
1103 config_override: Option<serde_yaml::Value>,
1104}
1105
1106fn parse_elicit_call_args(
1119 verb: &str,
1120 inside: &str,
1121 source: &str,
1122) -> Result<ParsedElicitCall, ParseError> {
1123 let parts = split_top_level_commas(inside).map_err(|msg| ParseError::Rule {
1124 rule: format!("{verb}({inside})"),
1125 msg: format!("{source}: {msg}"),
1126 })?;
1127 let mut parts_iter = parts.into_iter();
1128
1129 let plugin_name = parts_iter
1131 .next()
1132 .map(|s| strip_wrapping_quotes(s.trim()).to_string())
1133 .filter(|s| !s.is_empty())
1134 .ok_or_else(|| ParseError::Rule {
1135 rule: format!("{verb}({inside})"),
1136 msg: format!(
1137 "{source}: `{verb}(...)` requires an ElicitationHandler plugin name \
1138 as the first positional argument"
1139 ),
1140 })?;
1141
1142 let mut from: Option<String> = None;
1143 let mut channel: Option<String> = None;
1144 let mut purpose: Option<String> = None;
1145 let mut scope: Option<String> = None;
1146 let mut timeout: Option<String> = None;
1147 let mut on_error: Option<String> = None;
1148 let mut config_map = serde_yaml::Mapping::new();
1149
1150 let as_string = |value: serde_yaml::Value, key: &str| -> Result<String, ParseError> {
1152 match value {
1153 serde_yaml::Value::String(s) => Ok(s),
1154 other => Err(ParseError::Rule {
1155 rule: format!("{verb}(...)"),
1156 msg: format!("{source}: `{key}` must be a string, got {other:?}"),
1157 }),
1158 }
1159 };
1160
1161 for raw_kwarg in parts_iter {
1162 let kwarg = raw_kwarg.trim();
1163 if kwarg.is_empty() {
1164 continue;
1165 }
1166 let (key, value_str) = kwarg.split_once(':').ok_or_else(|| ParseError::Rule {
1167 rule: kwarg.to_string(),
1168 msg: format!(
1169 "{source}: `{verb}(...)` argument `{kwarg}` must be `key: value` \
1170 (the plugin name is the first positional argument)"
1171 ),
1172 })?;
1173 let key = key.trim();
1174 let value_str = value_str.trim();
1175 if key.is_empty() {
1176 return Err(ParseError::Rule {
1177 rule: kwarg.to_string(),
1178 msg: format!("{source}: `{verb}(...)` argument has empty key"),
1179 });
1180 }
1181 let value = parse_delegate_value(value_str).map_err(|msg| ParseError::Rule {
1182 rule: kwarg.to_string(),
1183 msg: format!("{source}: `{key}`: {msg}"),
1184 })?;
1185 match key {
1186 "from" => from = Some(as_string(value, "from")?),
1187 "channel" => channel = Some(as_string(value, "channel")?),
1188 "scope" => scope = Some(as_string(value, "scope")?),
1189 "purpose" | "prompt" => purpose = Some(as_string(value, key)?),
1190 "timeout" => timeout = Some(as_string(value, "timeout")?),
1191 "on_error" => on_error = Some(as_string(value, "on_error")?),
1192 "plugin" => {
1194 return Err(ParseError::Rule {
1195 rule: kwarg.to_string(),
1196 msg: format!(
1197 "{source}: the plugin name is the first positional argument \
1198 of `{verb}(...)`; don't pass it as a kwarg too"
1199 ),
1200 });
1201 },
1202 _ => {
1203 config_map.insert(serde_yaml::Value::String(key.to_string()), value);
1204 },
1205 }
1206 }
1207
1208 let from = from.ok_or_else(|| ParseError::Rule {
1209 rule: format!("{verb}({inside})"),
1210 msg: format!("{source}: `{verb}(...)` requires `from` (the approver)"),
1211 })?;
1212
1213 let config_override = if config_map.is_empty() {
1214 None
1215 } else {
1216 Some(serde_yaml::Value::Mapping(config_map))
1217 };
1218
1219 Ok(ParsedElicitCall {
1220 plugin_name,
1221 from,
1222 channel,
1223 purpose,
1224 scope,
1225 timeout,
1226 on_error,
1227 config_override,
1228 })
1229}
1230
1231fn split_top_level_commas(input: &str) -> Result<Vec<String>, String> {
1239 let mut parts = Vec::new();
1240 let mut current = String::new();
1241 let mut bracket_depth: usize = 0;
1242 let mut quote: Option<char> = None;
1243 let mut escape = false;
1244
1245 for ch in input.chars() {
1246 if escape {
1247 current.push(ch);
1248 escape = false;
1249 continue;
1250 }
1251 if let Some(q) = quote {
1252 current.push(ch);
1253 if ch == '\\' {
1254 escape = true;
1255 } else if ch == q {
1256 quote = None;
1257 }
1258 continue;
1259 }
1260 match ch {
1261 '"' | '\'' => {
1262 quote = Some(ch);
1263 current.push(ch);
1264 },
1265 '[' | '(' | '{' => {
1266 bracket_depth += 1;
1267 current.push(ch);
1268 },
1269 ']' | ')' | '}' => {
1270 bracket_depth = bracket_depth
1271 .checked_sub(1)
1272 .ok_or_else(|| format!("unmatched `{ch}` in delegate(...) args"))?;
1273 current.push(ch);
1274 },
1275 ',' if bracket_depth == 0 => {
1276 parts.push(std::mem::take(&mut current));
1277 },
1278 _ => current.push(ch),
1279 }
1280 }
1281 if quote.is_some() {
1282 return Err("unterminated quoted string in delegate(...) args".to_string());
1283 }
1284 if bracket_depth != 0 {
1285 return Err("unbalanced brackets in delegate(...) args".to_string());
1286 }
1287 parts.push(current);
1288 Ok(parts)
1289}
1290
1291fn parse_delegate_value(s: &str) -> Result<serde_yaml::Value, String> {
1295 let trimmed = s.trim();
1296 if trimmed.is_empty() {
1297 return Err("empty value".to_string());
1298 }
1299 if let Some(stripped) = trimmed.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
1301 let items = split_top_level_commas(stripped)?;
1302 let mut out = Vec::with_capacity(items.len());
1303 for item in items {
1304 let item = item.trim();
1305 if item.is_empty() {
1306 continue;
1307 }
1308 out.push(parse_delegate_value(item)?);
1309 }
1310 return Ok(serde_yaml::Value::Sequence(out));
1311 }
1312 if (trimmed.starts_with('"') && trimmed.ends_with('"') && trimmed.len() >= 2)
1314 || (trimmed.starts_with('\'') && trimmed.ends_with('\'') && trimmed.len() >= 2)
1315 {
1316 return Ok(serde_yaml::Value::String(
1317 trimmed[1..trimmed.len() - 1].to_string(),
1318 ));
1319 }
1320 if trimmed == "true" {
1322 return Ok(serde_yaml::Value::Bool(true));
1323 }
1324 if trimmed == "false" {
1325 return Ok(serde_yaml::Value::Bool(false));
1326 }
1327 if let Ok(n) = trimmed.parse::<i64>() {
1329 return Ok(serde_yaml::Value::Number(serde_yaml::Number::from(n)));
1330 }
1331 if let Ok(f) = trimmed.parse::<f64>() {
1332 return Ok(serde_yaml::Value::Number(serde_yaml::Number::from(f)));
1333 }
1334 Ok(serde_yaml::Value::String(trimmed.to_string()))
1337}
1338
1339fn strip_wrapping_quotes(s: &str) -> &str {
1343 let bytes = s.as_bytes();
1344 if bytes.len() >= 2 {
1345 let first = bytes[0];
1346 let last = bytes[bytes.len() - 1];
1347 if (first == b'"' && last == b'"') || (first == b'\'' && last == b'\'') {
1348 return &s[1..s.len() - 1];
1349 }
1350 }
1351 s
1352}
1353
1354fn parse_step_map(m: &serde_yaml::Mapping, source: &str) -> Result<Step, ParseError> {
1355 if has_key(m, "when") && has_key(m, "do") {
1360 return parse_when_do_rule(m, source);
1361 }
1362
1363 if m.len() != 1 {
1364 return Err(ParseError::Rule {
1365 rule: format!("{:?}", m),
1366 msg: "step map must have exactly one key (PDP call signature, \
1367 `when:`/`do:`, or a `predicate: [effects...]` shorthand)"
1368 .into(),
1369 });
1370 }
1371 let (key_val, body_val) = m.iter().next().unwrap();
1372 let key = key_val.as_str().ok_or_else(|| ParseError::Rule {
1373 rule: format!("{:?}", key_val),
1374 msg: "PDP step key must be a string".into(),
1375 })?;
1376
1377 if let serde_yaml::Value::Sequence(items) = body_val {
1385 let trimmed = key.trim();
1392 if trimmed != "delegate"
1393 && trimmed != "sequential"
1394 && trimmed != "parallel"
1395 && !is_known_pdp_dialect(trimmed)
1396 {
1397 return parse_shorthand_multi_effect(trimmed, items, source);
1398 }
1399 }
1400
1401 if key.trim() == "delegate" {
1404 return parse_delegate_step(body_val, source);
1405 }
1406
1407 match key.trim() {
1411 "sequential" => {
1412 let effect = parse_sequential_effect(body_val, source)?;
1413 return Ok(Step::Rule(Rule {
1414 condition: Expression::Always,
1415 effects: vec![effect],
1416 source: source.to_string(),
1417 }));
1418 },
1419 "parallel" => {
1420 let effect = parse_parallel_effect(body_val, source)?;
1421 return Ok(Step::Rule(Rule {
1422 condition: Expression::Always,
1423 effects: vec![effect],
1424 source: source.to_string(),
1425 }));
1426 },
1427 _ => {},
1428 }
1429
1430 let (dialect_str, paren_args) = if let Some(open) = key.find('(') {
1432 let close = key.rfind(')').ok_or_else(|| ParseError::Rule {
1433 rule: key.to_string(),
1434 msg: "missing `)` in PDP call signature".into(),
1435 })?;
1436 let inside = key[open + 1..close].trim().to_string();
1437 (key[..open].trim(), Some(inside))
1438 } else {
1439 (key.trim(), None)
1440 };
1441
1442 let dialect = PdpDialect::from_key(dialect_str);
1443
1444 let body = body_val.as_mapping().ok_or_else(|| ParseError::Rule {
1448 rule: format!("{:?}", body_val),
1449 msg: format!(
1450 "`{}:` body must be a map (with on_deny / on_allow / args)",
1451 key
1452 ),
1453 })?;
1454
1455 let (args, on_deny, on_allow) = extract_pdp_body(body, paren_args.as_deref(), source)?;
1456
1457 Ok(Step::Pdp {
1458 call: PdpCall { dialect, args },
1459 on_deny,
1460 on_allow,
1461 })
1462}
1463
1464fn has_key(m: &serde_yaml::Mapping, key: &str) -> bool {
1483 m.contains_key(serde_yaml::Value::String(key.to_string()))
1484}
1485
1486fn is_known_pdp_dialect(key: &str) -> bool {
1490 let base = key.find('(').map(|i| &key[..i]).unwrap_or(key);
1491 matches!(base.trim(), "cedar" | "opa" | "authzen" | "nemo" | "cel")
1492}
1493
1494fn parse_when_do_rule(m: &serde_yaml::Mapping, source: &str) -> Result<Step, ParseError> {
1500 for (k, _) in m.iter() {
1505 let key = k.as_str().unwrap_or("");
1506 if !matches!(key, "when" | "do" | "id") {
1507 return Err(ParseError::Rule {
1508 rule: format!("{:?}", m),
1509 msg: format!(
1510 "unexpected key `{}` in when/do rule (allowed: `when`, `do`, `id`)",
1511 key
1512 ),
1513 });
1514 }
1515 }
1516
1517 let when_val = m
1518 .get(serde_yaml::Value::String("when".into()))
1519 .expect("has_key verified above");
1520 let predicate = when_val.as_str().ok_or_else(|| ParseError::Rule {
1521 rule: format!("{:?}", when_val),
1522 msg: "`when:` must be a predicate string".into(),
1523 })?;
1524 let condition = parse_predicate(predicate).map_err(|e| ParseError::Rule {
1525 rule: format!("when: {}", predicate),
1526 msg: format!("{}", e),
1527 })?;
1528
1529 let do_val = m
1530 .get(serde_yaml::Value::String("do".into()))
1531 .expect("has_key verified above");
1532 let effects = parse_do_body(do_val, source)?;
1533 if effects.is_empty() {
1534 return Err(ParseError::Rule {
1535 rule: format!("{:?}", m),
1536 msg: "`do:` produced no effects".into(),
1537 });
1538 }
1539
1540 Ok(Step::Rule(Rule {
1541 condition,
1542 effects,
1543 source: source.to_string(),
1544 }))
1545}
1546
1547fn parse_shorthand_multi_effect(
1551 predicate: &str,
1552 effect_list: &[serde_yaml::Value],
1553 source: &str,
1554) -> Result<Step, ParseError> {
1555 let condition = parse_predicate(predicate).map_err(|e| ParseError::Rule {
1556 rule: predicate.to_string(),
1557 msg: format!("{}", e),
1558 })?;
1559
1560 let mut effects = Vec::with_capacity(effect_list.len());
1561 for item in effect_list {
1562 effects.push(parse_effect_value(item, source)?);
1563 }
1564 if effects.is_empty() {
1565 return Err(ParseError::Rule {
1566 rule: predicate.to_string(),
1567 msg: "shorthand multi-effect map produced no effects".into(),
1568 });
1569 }
1570 Ok(Step::Rule(Rule {
1571 condition,
1572 effects,
1573 source: source.to_string(),
1574 }))
1575}
1576
1577fn parse_do_body(val: &serde_yaml::Value, source: &str) -> Result<Vec<Effect>, ParseError> {
1581 match val {
1582 serde_yaml::Value::String(s) => Ok(vec![parse_effect_string(s, source)?]),
1583 serde_yaml::Value::Sequence(items) => items
1584 .iter()
1585 .map(|item| parse_effect_value(item, source))
1586 .collect(),
1587 serde_yaml::Value::Mapping(_) => {
1588 Ok(vec![parse_effect_value(val, source)?])
1591 },
1592 other => Err(ParseError::Rule {
1593 rule: format!("{:?}", other),
1594 msg: "`do:` value must be a string, a list of effects, or an effect map".into(),
1595 }),
1596 }
1597}
1598
1599fn parse_effect_value(val: &serde_yaml::Value, source: &str) -> Result<Effect, ParseError> {
1603 match val {
1604 serde_yaml::Value::String(s) => parse_effect_string(s, source),
1605 serde_yaml::Value::Mapping(m) => {
1606 if m.len() == 1 {
1610 let (k, v) = m.iter().next().unwrap();
1611 if let Some(key_str) = k.as_str() {
1612 match key_str.trim() {
1613 "sequential" => return parse_sequential_effect(v, source),
1614 "parallel" => return parse_parallel_effect(v, source),
1615 _ => {},
1616 }
1617 }
1618 }
1619 let step = parse_step(val, source)?;
1622 step_to_effect(step, source)
1623 },
1624 other => Err(ParseError::Rule {
1625 rule: format!("{:?}", other),
1626 msg: "effect entry must be a string or a map".into(),
1627 }),
1628 }
1629}
1630
1631fn parse_sequential_effect(body: &serde_yaml::Value, source: &str) -> Result<Effect, ParseError> {
1634 let items = body.as_sequence().ok_or_else(|| ParseError::Rule {
1635 rule: format!("{:?}", body),
1636 msg: "`sequential:` body must be a list of effects".into(),
1637 })?;
1638 if items.is_empty() {
1639 return Err(ParseError::Rule {
1640 rule: format!("{:?}", body),
1641 msg: "`sequential:` body is empty".into(),
1642 });
1643 }
1644 let mut effects = Vec::with_capacity(items.len());
1645 for item in items {
1646 effects.push(parse_effect_value(item, source)?);
1647 }
1648 Ok(Effect::Sequential(effects))
1649}
1650
1651fn parse_parallel_effect(body: &serde_yaml::Value, source: &str) -> Result<Effect, ParseError> {
1655 let items = body.as_sequence().ok_or_else(|| ParseError::Rule {
1656 rule: format!("{:?}", body),
1657 msg: "`parallel:` body must be a list of effects".into(),
1658 })?;
1659 if items.is_empty() {
1660 return Err(ParseError::Rule {
1661 rule: format!("{:?}", body),
1662 msg: "`parallel:` body is empty".into(),
1663 });
1664 }
1665 let mut effects = Vec::with_capacity(items.len());
1666 for item in items {
1667 effects.push(parse_effect_value(item, source)?);
1668 }
1669 let parallel = Effect::Parallel(effects);
1670 parallel
1671 .validate_parallel_purity()
1672 .map_err(|msg| ParseError::Rule {
1673 rule: source.to_string(),
1674 msg,
1675 })?;
1676 Ok(parallel)
1677}
1678
1679fn parse_effect_string(s: &str, source: &str) -> Result<Effect, ParseError> {
1684 let trimmed = s.trim();
1689 if let Some(mut effects) = try_bare_action(trimmed) {
1690 if effects.len() == 1 {
1691 return Ok(effects.pop().unwrap());
1692 }
1693 }
1694 if let Some(effect) = try_parse_deny_call(trimmed, s)? {
1695 return Ok(effect);
1696 }
1697 if let Some(field_op) = try_parse_field_op(trimmed, s)? {
1702 return Ok(field_op);
1703 }
1704 let step = parse_step_string(s, source)?;
1707 step_to_effect(step, source)
1708}
1709
1710fn try_parse_field_op(s: &str, rule: &str) -> Result<Option<Effect>, ParseError> {
1714 let Some(pipe_idx) = find_top_level_pipe(s) else {
1715 return Ok(None);
1716 };
1717 let path = s[..pipe_idx].trim();
1718 let chain = s[pipe_idx + 1..].trim();
1719 if path.is_empty() || chain.is_empty() {
1720 return Ok(None);
1721 }
1722 if !is_valid_field_path(path) {
1727 return Ok(None);
1728 }
1729 let pipeline = parse_pipeline(chain).map_err(|e| ParseError::Rule {
1730 rule: rule.to_string(),
1731 msg: format!("field op `{}`: {}", path, e),
1732 })?;
1733 if pipeline.stages.is_empty() {
1734 return Err(ParseError::Rule {
1735 rule: rule.to_string(),
1736 msg: format!("field op `{}` has no stages", path),
1737 });
1738 }
1739 Ok(Some(Effect::FieldOp {
1740 path: path.to_string(),
1741 stages: pipeline.stages,
1742 }))
1743}
1744
1745fn find_top_level_pipe(s: &str) -> Option<usize> {
1749 let bytes = s.as_bytes();
1750 let mut depth: i32 = 0;
1751 let mut quote: Option<u8> = None;
1752 let mut i = 0;
1753 while i < bytes.len() {
1754 let b = bytes[i];
1755 if let Some(q) = quote {
1756 if b == b'\\' {
1757 i += 2;
1758 continue;
1759 }
1760 if b == q {
1761 quote = None;
1762 }
1763 i += 1;
1764 continue;
1765 }
1766 match b {
1767 b'\'' | b'"' => quote = Some(b),
1768 b'(' | b'[' => depth += 1,
1769 b')' | b']' => depth -= 1,
1770 b'|' if depth == 0 => {
1771 if bytes.get(i + 1) == Some(&b'|') {
1774 i += 2;
1775 continue;
1776 }
1777 return Some(i);
1778 },
1779 _ => {},
1780 }
1781 i += 1;
1782 }
1783 None
1784}
1785
1786fn is_valid_field_path(s: &str) -> bool {
1790 let Some(rest) = s
1791 .strip_prefix("args.")
1792 .or_else(|| s.strip_prefix("result."))
1793 else {
1794 return false;
1795 };
1796 !rest.is_empty()
1797 && rest
1798 .split('.')
1799 .all(|seg| !seg.is_empty() && seg.chars().all(|c| c.is_alphanumeric() || c == '_'))
1800}
1801
1802pub(crate) fn step_to_top_level_effect(step: Step) -> Result<Effect, ParseError> {
1813 match step {
1814 Step::Rule(rule) => Ok(Effect::When {
1815 condition: rule.condition,
1816 body: rule.effects,
1817 source: rule.source,
1818 }),
1819 Step::Pdp {
1820 call,
1821 on_allow,
1822 on_deny,
1823 } => {
1824 let on_allow = on_allow
1825 .into_iter()
1826 .map(step_to_top_level_effect)
1827 .collect::<Result<Vec<_>, _>>()?;
1828 let on_deny = on_deny
1829 .into_iter()
1830 .map(step_to_top_level_effect)
1831 .collect::<Result<Vec<_>, _>>()?;
1832 Ok(Effect::Pdp {
1833 call,
1834 on_allow,
1835 on_deny,
1836 })
1837 },
1838 Step::Plugin { name } => Ok(Effect::Plugin { name }),
1839 Step::Delegate(d) => Ok(Effect::Delegate(d)),
1840 Step::Elicit(e) => Ok(Effect::Elicit(e)),
1841 Step::Taint { label, scopes } => Ok(Effect::Taint { label, scopes }),
1842 }
1843}
1844
1845fn step_to_effect(step: Step, source: &str) -> Result<Effect, ParseError> {
1846 match step {
1847 Step::Plugin { name } => Ok(Effect::Plugin { name }),
1848 Step::Delegate(d) => Ok(Effect::Delegate(d)),
1849 Step::Elicit(e) => Ok(Effect::Elicit(e)),
1850 Step::Taint { label, scopes } => Ok(Effect::Taint { label, scopes }),
1851 Step::Rule(rule) => {
1852 if !matches!(rule.condition, Expression::Always) {
1855 return Err(ParseError::Rule {
1856 rule: source.to_string(),
1857 msg: "conditional rules nested inside `do:` are not supported in E1 \
1858 (use a sibling `when:`/`do:` rule instead)"
1859 .into(),
1860 });
1861 }
1862 if rule.effects.len() != 1 {
1863 return Err(ParseError::Rule {
1864 rule: source.to_string(),
1865 msg: format!(
1866 "unconditional rule inside `do:` must produce exactly one \
1867 effect, got {}",
1868 rule.effects.len()
1869 ),
1870 });
1871 }
1872 Ok(rule.effects.into_iter().next().unwrap())
1873 },
1874 Step::Pdp { .. } => Err(ParseError::Rule {
1875 rule: source.to_string(),
1876 msg: "PDP calls inside `do:` are not supported in E1 (use a sibling \
1877 step instead)"
1878 .into(),
1879 }),
1880 }
1881}
1882
1883fn parse_delegate_step(body_val: &serde_yaml::Value, source: &str) -> Result<Step, ParseError> {
1888 let body = body_val.as_mapping().ok_or_else(|| ParseError::Rule {
1889 rule: source.to_string(),
1890 msg: "`delegate:` body must be a map with `plugin:` and optional \
1891 `config:` / `on_error:`"
1892 .to_string(),
1893 })?;
1894
1895 let plugin = body
1896 .get(serde_yaml::Value::String("plugin".to_string()))
1897 .ok_or_else(|| ParseError::Rule {
1898 rule: source.to_string(),
1899 msg: "`delegate:` requires `plugin: <name>` referencing a \
1900 top-level plugin registered under `token.delegate`"
1901 .to_string(),
1902 })?;
1903 let plugin_name = plugin
1904 .as_str()
1905 .ok_or_else(|| ParseError::Rule {
1906 rule: source.to_string(),
1907 msg: "`delegate.plugin` must be a string".to_string(),
1908 })?
1909 .to_string();
1910 if plugin_name.is_empty() {
1911 return Err(ParseError::Rule {
1912 rule: source.to_string(),
1913 msg: "`delegate.plugin` cannot be empty".to_string(),
1914 });
1915 }
1916
1917 let config_override = body
1918 .get(serde_yaml::Value::String("config".to_string()))
1919 .cloned();
1920
1921 let on_error = match body.get(serde_yaml::Value::String("on_error".to_string())) {
1922 Some(v) => Some(
1923 v.as_str()
1924 .ok_or_else(|| ParseError::Rule {
1925 rule: source.to_string(),
1926 msg: "`delegate.on_error` must be a string (e.g. `deny`, \
1927 `continue`)"
1928 .to_string(),
1929 })?
1930 .to_string(),
1931 ),
1932 None => None,
1933 };
1934
1935 Ok(Step::Delegate(DelegateStep {
1936 plugin_name,
1937 config_override,
1938 on_error,
1939 source: source.to_string(),
1940 }))
1941}
1942
1943fn extract_pdp_body(
1950 body: &serde_yaml::Mapping,
1951 paren_args: Option<&str>,
1952 source: &str,
1953) -> Result<(serde_yaml::Value, Vec<Step>, Vec<Step>), ParseError> {
1954 let mut on_deny = Vec::new();
1955 let mut on_allow = Vec::new();
1956 let mut args_map = serde_yaml::Mapping::new();
1957
1958 for (k, v) in body {
1959 match k.as_str() {
1960 Some("on_deny") => {
1961 on_deny = parse_reaction_list(v, source, "on_deny")?;
1962 },
1963 Some("on_allow") => {
1964 on_allow = parse_reaction_list(v, source, "on_allow")?;
1965 },
1966 _ => {
1967 args_map.insert(k.clone(), v.clone());
1969 },
1970 }
1971 }
1972
1973 let args = match paren_args {
1974 Some(s) => serde_yaml::Value::String(s.to_string()),
1975 None => serde_yaml::Value::Mapping(args_map),
1976 };
1977
1978 Ok((args, on_deny, on_allow))
1979}
1980
1981fn parse_reaction_list(
1982 v: &serde_yaml::Value,
1983 source: &str,
1984 which: &str,
1985) -> Result<Vec<Step>, ParseError> {
1986 let list = v.as_sequence().ok_or_else(|| ParseError::Rule {
1987 rule: format!("{:?}", v),
1988 msg: format!("`{}:` must be a list of steps", which),
1989 })?;
1990 list.iter()
1991 .enumerate()
1992 .map(|(i, entry)| parse_step(entry, &format!("{}.{}[{}]", source, which, i)))
1993 .collect()
1994}
1995
1996fn extract_call_args(line: &str, name: &str) -> Option<String> {
1999 let line = line.trim();
2000 if !line.starts_with(name) {
2001 return None;
2002 }
2003 let after = &line[name.len()..];
2004 if !after.starts_with('(') {
2005 return None;
2006 }
2007 let bytes = after.as_bytes();
2009 let mut depth = 0;
2010 for (i, &b) in bytes.iter().enumerate() {
2011 match b {
2012 b'(' => depth += 1,
2013 b')' => {
2014 depth -= 1;
2015 if depth == 0 {
2016 if after[i + 1..].trim().is_empty() {
2018 return Some(after[1..i].to_string());
2019 }
2020 return None;
2021 }
2022 },
2023 _ => {},
2024 }
2025 }
2026 None
2027}
2028
2029pub fn parse_pipeline(src: &str) -> Result<Pipeline, ParseError> {
2039 let mut pipeline = Pipeline::new();
2040 for seg in split_top_level(src.trim(), b'|') {
2041 let seg = seg.trim();
2042 if seg.is_empty() {
2043 continue;
2044 }
2045 pipeline.push(parse_stage(seg)?);
2046 }
2047 Ok(pipeline)
2048}
2049
2050fn split_top_level(s: &str, delim: u8) -> Vec<&str> {
2052 let bytes = s.as_bytes();
2053 let mut out = Vec::new();
2054 let mut depth: i32 = 0;
2055 let mut in_quote: Option<u8> = None;
2056 let mut start = 0;
2057 for (i, &b) in bytes.iter().enumerate() {
2058 match (in_quote, b) {
2059 (Some(q), c) if c == q => in_quote = None,
2060 (Some(_), _) => {},
2061 (None, b'"') | (None, b'\'') => in_quote = Some(b),
2062 (None, b'(') | (None, b'[') => depth += 1,
2063 (None, b')') | (None, b']') => depth -= 1,
2064 (None, c) if c == delim && depth == 0 => {
2065 out.push(&s[start..i]);
2066 start = i + 1;
2067 },
2068 _ => {},
2069 }
2070 }
2071 out.push(&s[start..]);
2072 out
2073}
2074
2075fn parse_stage(src: &str) -> Result<Stage, ParseError> {
2076 let s = src.trim();
2077 let bad = |msg: &str| ParseError::Predicate {
2078 predicate: src.to_string(),
2079 msg: msg.to_string(),
2080 };
2081
2082 if let Some(stage) = try_parse_range(s) {
2084 return Ok(stage);
2085 }
2086
2087 let (head, args) = split_head_args(s).ok_or_else(|| bad("expected stage identifier"))?;
2090
2091 match (head, args.as_deref()) {
2092 ("str", None) => Ok(Stage::Type(TypeCheck::Str)),
2094 ("int", None) => Ok(Stage::Type(TypeCheck::Int)),
2095 ("bool", None) => Ok(Stage::Type(TypeCheck::Bool)),
2096 ("float", None) => Ok(Stage::Type(TypeCheck::Float)),
2097 ("email", None) => Ok(Stage::Type(TypeCheck::Email)),
2098 ("url", None) => Ok(Stage::Type(TypeCheck::Url)),
2099 ("uuid", None) => Ok(Stage::Type(TypeCheck::Uuid)),
2100 ("redact", None) => Ok(Stage::Redact { condition: None }),
2101 ("omit", None) => Ok(Stage::Omit),
2102 ("hash", None) => Ok(Stage::Hash),
2103 ("pii.redact", None) => Ok(Stage::Scan {
2105 kind: ScanKind::PiiRedact,
2106 }),
2107 ("pii.detect", None) => Ok(Stage::Scan {
2108 kind: ScanKind::PiiDetect,
2109 }),
2110 ("injection.scan", None) => Ok(Stage::Scan {
2111 kind: ScanKind::InjectionScan,
2112 }),
2113
2114 ("mask", Some(a)) => {
2116 let n: usize = a
2117 .trim()
2118 .parse()
2119 .map_err(|_| bad(&format!("mask(N) expects integer, got `{}`", a)))?;
2120 Ok(Stage::Mask { keep_last: n })
2121 },
2122 ("redact", Some(a)) => {
2123 let cond = parse_predicate(a).map_err(|e| ParseError::Predicate {
2125 predicate: src.to_string(),
2126 msg: format!("invalid redact() condition: {}", e),
2127 })?;
2128 Ok(Stage::Redact {
2129 condition: Some(cond),
2130 })
2131 },
2132 ("hash", Some(_)) => Err(bad("hash takes no arguments")),
2133 ("omit", Some(_)) => Err(bad(
2134 "omit takes no arguments — for conditional omit, use a policy rule predicate",
2135 )),
2136 ("len", Some(a)) => {
2137 let (min, max) = parse_range_inner(a)
2138 .ok_or_else(|| bad(&format!("len(...) expects N..M range, got `{}`", a)))?;
2139 let to_usize = |v: i64| -> Result<usize, ParseError> {
2140 if v < 0 {
2141 Err(bad("len bounds must be non-negative"))
2142 } else {
2143 Ok(v as usize)
2144 }
2145 };
2146 Ok(Stage::Length {
2147 min: min.map(to_usize).transpose()?,
2148 max: max.map(to_usize).transpose()?,
2149 })
2150 },
2151 ("enum", Some(a)) => {
2152 let values = split_top_level(a, b',')
2153 .into_iter()
2154 .map(|v| {
2155 let t = v.trim();
2156 if (t.starts_with('"') && t.ends_with('"'))
2158 || (t.starts_with('\'') && t.ends_with('\''))
2159 {
2160 t[1..t.len() - 1].to_string()
2161 } else {
2162 t.to_string()
2163 }
2164 })
2165 .filter(|s| !s.is_empty())
2166 .collect::<Vec<_>>();
2167 if values.is_empty() {
2168 return Err(bad("enum() requires at least one value"));
2169 }
2170 Ok(Stage::Enum { values })
2171 },
2172 ("regex", Some(a)) => {
2173 let pattern = a.trim();
2174 let pat = if (pattern.starts_with('"') && pattern.ends_with('"'))
2175 || (pattern.starts_with('\'') && pattern.ends_with('\''))
2176 {
2177 pattern[1..pattern.len() - 1].to_string()
2178 } else {
2179 pattern.to_string()
2180 };
2181 Ok(Stage::Regex { pattern: pat })
2182 },
2183 ("validate", Some(a)) => {
2184 Err(bad(&format!(
2198 "`validate({})` — named-validator dispatch is not implemented \
2199 in this build. Use `regex(\"pattern\")` for a named-regex \
2200 equivalent, or `plugin({})` for richer validation logic.",
2201 a.trim(),
2202 a.trim(),
2203 )))
2204 },
2205 ("plugin" | "run", Some(a)) => {
2207 let name = a.trim();
2208 if name.is_empty() {
2209 return Err(bad(&format!(
2213 "`{head}(...)`: plugin name must not be empty"
2214 )));
2215 }
2216 Ok(Stage::Plugin {
2217 name: name.to_string(),
2218 })
2219 },
2220 ("taint", Some(a)) => parse_taint(a, src),
2221
2222 (other, _) => Err(bad(&format!("unknown stage `{}`", other))),
2223 }
2224}
2225
2226fn try_parse_range(s: &str) -> Option<Stage> {
2228 if !s.contains("..") {
2229 return None;
2230 }
2231 let first = s.as_bytes().first().copied()?;
2233 if first.is_ascii_alphabetic() || first == b'_' {
2234 return None;
2235 }
2236 let (min, max) = parse_range_inner(s)?;
2237 Some(Stage::Range { min, max })
2238}
2239
2240fn parse_range_inner(s: &str) -> Option<(Option<i64>, Option<i64>)> {
2243 let dotdot = s.find("..")?;
2244 let left = s[..dotdot].trim();
2245 let right = s[dotdot + 2..].trim();
2246 let min = if left.is_empty() {
2247 None
2248 } else {
2249 Some(parse_numeric_with_suffix(left)?)
2250 };
2251 let max = if right.is_empty() {
2252 None
2253 } else {
2254 Some(parse_numeric_with_suffix(right)?)
2255 };
2256 if min.is_none() && max.is_none() {
2257 return None; }
2259 Some((min, max))
2260}
2261
2262fn parse_numeric_with_suffix(s: &str) -> Option<i64> {
2264 let s = s.trim();
2265 if s.is_empty() {
2266 return None;
2267 }
2268 let (num_part, mult) = match s.as_bytes().last().copied()? {
2269 b'k' | b'K' => (&s[..s.len() - 1], 1_000_i64),
2270 b'm' | b'M' => (&s[..s.len() - 1], 1_000_000_i64),
2271 _ => (s, 1_i64),
2272 };
2273 let n: i64 = num_part.parse().ok()?;
2274 n.checked_mul(mult)
2275}
2276
2277fn split_head_args(s: &str) -> Option<(&str, Option<String>)> {
2280 if let Some(open) = s.find('(') {
2281 let bytes = s.as_bytes();
2283 let mut depth = 0;
2284 let mut close = None;
2285 for (i, &b) in bytes.iter().enumerate().skip(open) {
2286 match b {
2287 b'(' => depth += 1,
2288 b')' => {
2289 depth -= 1;
2290 if depth == 0 {
2291 close = Some(i);
2292 break;
2293 }
2294 },
2295 _ => {},
2296 }
2297 }
2298 let close = close?;
2299 let head = s[..open].trim();
2300 if head.is_empty() {
2301 return None;
2302 }
2303 let args = s[open + 1..close].to_string();
2304 if s[close + 1..].trim().is_empty() {
2306 Some((head, Some(args)))
2307 } else {
2308 None
2309 }
2310 } else {
2311 let head = s.trim();
2312 if head.is_empty() {
2313 None
2314 } else {
2315 Some((head, None))
2316 }
2317 }
2318}
2319
2320fn parse_taint(args: &str, src: &str) -> Result<Stage, ParseError> {
2321 let parts = split_top_level(args, b',');
2323 if parts.is_empty() {
2324 return Err(ParseError::Predicate {
2325 predicate: src.to_string(),
2326 msg: "taint() requires at least a label".into(),
2327 });
2328 }
2329 let label = parts[0].trim().to_string();
2330 if label.is_empty() {
2331 return Err(ParseError::Predicate {
2332 predicate: src.to_string(),
2333 msg: "taint label must not be empty".into(),
2334 });
2335 }
2336
2337 let scopes = if parts.len() == 1 {
2338 vec![TaintScope::Session] } else {
2340 let scope_arg = parts[1..].join(",");
2341 let scope_arg = scope_arg.trim();
2342 if scope_arg.starts_with('[') && scope_arg.ends_with(']') {
2343 split_top_level(&scope_arg[1..scope_arg.len() - 1], b',')
2344 .into_iter()
2345 .map(|s| parse_taint_scope(s.trim(), src))
2346 .collect::<Result<Vec<_>, _>>()?
2347 } else {
2348 vec![parse_taint_scope(scope_arg, src)?]
2349 }
2350 };
2351
2352 Ok(Stage::Taint { label, scopes })
2353}
2354
2355fn parse_taint_scope(s: &str, src: &str) -> Result<TaintScope, ParseError> {
2356 match s {
2357 "session" => Ok(TaintScope::Session),
2358 "message" => Ok(TaintScope::Message),
2359 other => Err(ParseError::Predicate {
2360 predicate: src.to_string(),
2361 msg: format!(
2362 "unknown taint scope `{}` (expected `session` or `message`)",
2363 other
2364 ),
2365 }),
2366 }
2367}
2368
2369#[derive(Debug, Default, Deserialize)]
2383pub struct ConfigYaml {
2384 #[serde(default)]
2385 pub routes: HashMap<String, RouteYaml>,
2386
2387 #[serde(default)]
2389 pub plugins: Vec<PluginDeclaration>,
2390
2391 #[serde(flatten)]
2393 pub other: HashMap<String, serde_yaml::Value>,
2394}
2395
2396#[derive(Debug, Default, Deserialize)]
2397pub struct RouteYaml {
2398 #[serde(default)]
2403 pub pre_invocation: Vec<serde_yaml::Value>,
2404
2405 #[serde(default)]
2408 pub post_invocation: Vec<serde_yaml::Value>,
2409
2410 #[serde(default)]
2413 pub authorization: Option<AuthorizationYaml>,
2414
2415 #[serde(default)]
2417 pub args: HashMap<String, String>,
2418
2419 #[serde(default)]
2421 pub result: HashMap<String, String>,
2422
2423 #[serde(default)]
2427 pub plugins: HashMap<String, PluginOverride>,
2428
2429 #[serde(flatten)]
2433 pub other: HashMap<String, serde_yaml::Value>,
2434}
2435
2436#[derive(Debug, Default, Deserialize)]
2449#[serde(deny_unknown_fields)]
2450pub struct AuthorizationYaml {
2451 #[serde(default)]
2452 pub pre_invocation: Vec<serde_yaml::Value>,
2453
2454 #[serde(default)]
2455 pub post_invocation: Vec<serde_yaml::Value>,
2456}
2457
2458#[derive(Debug, Default)]
2465pub struct CompiledConfig {
2466 pub routes: HashMap<String, CompiledRoute>,
2467 pub plugins: PluginRegistry,
2468}
2469
2470pub fn compile_config(yaml: &str) -> Result<CompiledConfig, ParseError> {
2481 let cfg: ConfigYaml = serde_yaml::from_str(yaml)?;
2482 let mut routes = HashMap::with_capacity(cfg.routes.len());
2483 for (route_key, raw) in cfg.routes {
2484 if let Some(route) = compile_route(&route_key, raw)? {
2485 routes.insert(route_key, route);
2486 }
2487 }
2488 let mut plugins = PluginRegistry::with_capacity(cfg.plugins.len());
2489 for decl in cfg.plugins {
2490 plugins.insert(decl.name.clone(), decl);
2493 }
2494 Ok(CompiledConfig { routes, plugins })
2495}
2496
2497const RENAMED_FIELDS: [(&str, &str); 2] = [
2503 (
2504 "policy",
2505 "authorization.pre_invocation (or flat pre_invocation)",
2506 ),
2507 (
2508 "post_policy",
2509 "authorization.post_invocation (or flat post_invocation)",
2510 ),
2511];
2512
2513fn reject_legacy_keys(
2516 location: &str,
2517 other: &HashMap<String, serde_yaml::Value>,
2518) -> Result<(), ParseError> {
2519 for (old, new) in RENAMED_FIELDS {
2520 if other.contains_key(old) {
2521 return Err(ParseError::RenamedField {
2522 location: location.to_string(),
2523 old: old.to_string(),
2524 new: new.to_string(),
2525 });
2526 }
2527 }
2528 Ok(())
2529}
2530
2531fn compile_route(route_key: &str, raw: RouteYaml) -> Result<Option<CompiledRoute>, ParseError> {
2532 reject_legacy_keys(route_key, &raw.other)?;
2535 let has_authz = raw
2536 .authorization
2537 .as_ref()
2538 .is_some_and(|a| !a.pre_invocation.is_empty() || !a.post_invocation.is_empty());
2539 let has_apl = !raw.pre_invocation.is_empty()
2540 || !raw.post_invocation.is_empty()
2541 || has_authz
2542 || !raw.args.is_empty()
2543 || !raw.result.is_empty();
2544 if !has_apl {
2545 return Ok(None);
2546 }
2547 Ok(Some(compile_apl_blocks(route_key, raw)?))
2548}
2549
2550fn compile_apl_blocks(source: &str, raw: RouteYaml) -> Result<CompiledRoute, ParseError> {
2561 reject_legacy_keys(source, &raw.other)?;
2562 let mut route = CompiledRoute::new(source);
2563 let (auth_pre, auth_post) = raw
2564 .authorization
2565 .map(|a| (a.pre_invocation, a.post_invocation))
2566 .unwrap_or_default();
2567 if !auth_pre.is_empty() && !raw.pre_invocation.is_empty() {
2573 return Err(ParseError::ConflictingAuthorizationForms {
2574 location: source.to_string(),
2575 phase: "pre_invocation".to_string(),
2576 });
2577 }
2578 if !auth_post.is_empty() && !raw.post_invocation.is_empty() {
2579 return Err(ParseError::ConflictingAuthorizationForms {
2580 location: source.to_string(),
2581 phase: "post_invocation".to_string(),
2582 });
2583 }
2584 for (i, entry) in auth_pre.iter().chain(raw.pre_invocation.iter()).enumerate() {
2585 let step = parse_step(entry, &format!("{}.pre_invocation[{}]", source, i))?;
2586 route.policy.push(step_to_top_level_effect(step)?);
2587 }
2588 for (i, entry) in auth_post
2589 .iter()
2590 .chain(raw.post_invocation.iter())
2591 .enumerate()
2592 {
2593 let step = parse_step(entry, &format!("{}.post_invocation[{}]", source, i))?;
2594 route.post_policy.push(step_to_top_level_effect(step)?);
2595 }
2596 for (field, chain) in &raw.args {
2597 let pipeline = parse_pipeline(chain).map_err(|e| ParseError::Rule {
2598 rule: format!("args.{}: {:?}", field, chain),
2599 msg: format!("{}", e),
2600 })?;
2601 route.args.push(FieldRule {
2602 field: field.clone(),
2603 pipeline,
2604 source: format!("{}.args.{}", source, field),
2605 });
2606 }
2607 for (field, chain) in &raw.result {
2608 let pipeline = parse_pipeline(chain).map_err(|e| ParseError::Rule {
2609 rule: format!("result.{}: {:?}", field, chain),
2610 msg: format!("{}", e),
2611 })?;
2612 route.result.push(FieldRule {
2613 field: field.clone(),
2614 pipeline,
2615 source: format!("{}.result.{}", source, field),
2616 });
2617 }
2618 route.plugin_overrides = raw.plugins;
2619 Ok(route)
2620}
2621
2622pub fn compile_policy_block_value(
2644 source: &str,
2645 block: &serde_yaml::Value,
2646) -> Result<CompiledRoute, ParseError> {
2647 if block.is_null() {
2648 return Ok(CompiledRoute::new(source));
2649 }
2650 let raw: RouteYaml = serde_yaml::from_value(block.clone())?;
2651 compile_apl_blocks(source, raw)
2652}
2653
2654#[cfg(test)]
2659mod tests {
2660 use super::*;
2661 use crate::attributes::AttributeBag;
2662 use crate::evaluator::Decision;
2663
2664 #[test]
2667 fn lex_basic() {
2668 let toks = Lexer::new("delegation.depth > 2").tokenize_all().unwrap();
2669 assert_eq!(
2670 toks,
2671 vec![
2672 Tok::Ident("delegation.depth".into()),
2673 Tok::Gt,
2674 Tok::IntLit(2),
2675 ]
2676 );
2677 }
2678
2679 #[test]
2680 fn lex_strings_both_quotes() {
2681 let a = Lexer::new(r#""double""#).tokenize_all().unwrap();
2682 let b = Lexer::new(r#"'single'"#).tokenize_all().unwrap();
2683 assert_eq!(a, vec![Tok::StringLit("double".into())]);
2684 assert_eq!(b, vec![Tok::StringLit("single".into())]);
2685 }
2686
2687 #[test]
2688 fn lex_keywords_vs_idents() {
2689 let toks = Lexer::new("require(role.hr) & authenticated")
2690 .tokenize_all()
2691 .unwrap();
2692 assert_eq!(
2693 toks,
2694 vec![
2695 Tok::Require,
2696 Tok::LParen,
2697 Tok::Ident("role.hr".into()),
2698 Tok::RParen,
2699 Tok::And,
2700 Tok::Ident("authenticated".into()),
2701 ]
2702 );
2703 }
2704
2705 #[test]
2706 fn lex_rejects_single_equals() {
2707 let err = Lexer::new("a = 1").tokenize_all().unwrap_err();
2708 assert!(format!("{}", err).contains("expected `==`"));
2709 }
2710
2711 #[test]
2714 fn pred_bare_identifier() {
2715 let e = parse_predicate("authenticated").unwrap();
2716 assert_eq!(
2717 e,
2718 Expression::Condition(Condition::IsTrue {
2719 key: "authenticated".into()
2720 })
2721 );
2722 }
2723
2724 #[test]
2725 fn pred_comparison() {
2726 let e = parse_predicate("delegation.depth > 2").unwrap();
2727 assert_eq!(
2728 e,
2729 Expression::Condition(Condition::Comparison {
2730 key: "delegation.depth".into(),
2731 op: CompareOp::Gt,
2732 value: Literal::Int(2),
2733 })
2734 );
2735 }
2736
2737 #[test]
2738 fn pred_contains() {
2739 let e = parse_predicate(r#"session.labels contains "PII""#).unwrap();
2740 assert_eq!(
2741 e,
2742 Expression::Condition(Condition::Comparison {
2743 key: "session.labels".into(),
2744 op: CompareOp::Contains,
2745 value: Literal::String("PII".into()),
2746 })
2747 );
2748 }
2749
2750 #[test]
2751 fn pred_precedence_or_lowest_and_middle_not_highest() {
2752 let e = parse_predicate("!a & b | c").unwrap();
2754 match e {
2755 Expression::Or(parts) => {
2756 assert_eq!(parts.len(), 2);
2757 match &parts[0] {
2758 Expression::And(_) => {},
2759 other => panic!("first OR branch should be AND, got {:?}", other),
2760 }
2761 },
2762 other => panic!("top-level should be OR, got {:?}", other),
2763 }
2764 }
2765
2766 #[test]
2767 fn pred_parens_override_precedence() {
2768 let e = parse_predicate("(role.finance | role.admin) & !delegated").unwrap();
2770 match e {
2771 Expression::And(parts) => {
2772 assert_eq!(parts.len(), 2);
2773 matches!(parts[0], Expression::Or(_));
2774 matches!(parts[1], Expression::Not(_));
2775 },
2776 other => panic!("expected top-level AND, got {:?}", other),
2777 }
2778 }
2779
2780 #[test]
2781 fn pred_require_rejected_as_predicate() {
2782 let err = parse_predicate("require(authenticated)").unwrap_err();
2785 assert!(format!("{}", err).contains("rule-level shorthand"));
2786 }
2787
2788 #[test]
2789 fn rule_require_single_arg_desugars_to_isfalse_and_deny() {
2790 let r = parse_rule("require(authenticated)", "test").unwrap();
2792 assert!(matches!(
2793 r.effects.as_slice(),
2794 [Effect::Deny {
2795 reason: None,
2796 code: None
2797 }]
2798 ));
2799 assert_eq!(
2800 r.condition,
2801 Expression::Condition(Condition::IsFalse {
2802 key: "authenticated".into()
2803 }),
2804 );
2805 }
2806
2807 #[test]
2808 fn rule_require_comma_is_and_desugars_to_or_of_isfalse() {
2809 let r = parse_rule("require(role.hr, perm.view_ssn)", "test").unwrap();
2812 assert_eq!(
2813 r.condition,
2814 Expression::Or(vec![
2815 Expression::Condition(Condition::IsFalse {
2816 key: "role.hr".into()
2817 }),
2818 Expression::Condition(Condition::IsFalse {
2819 key: "perm.view_ssn".into()
2820 }),
2821 ]),
2822 );
2823 }
2824
2825 #[test]
2826 fn rule_require_pipe_is_or_desugars_to_and_of_isfalse() {
2827 let r = parse_rule("require(role.finance | role.admin)", "test").unwrap();
2830 assert_eq!(
2831 r.condition,
2832 Expression::And(vec![
2833 Expression::Condition(Condition::IsFalse {
2834 key: "role.finance".into()
2835 }),
2836 Expression::Condition(Condition::IsFalse {
2837 key: "role.admin".into()
2838 }),
2839 ]),
2840 );
2841 }
2842
2843 #[test]
2844 fn rule_require_mixed_rejected() {
2845 let err = parse_rule("require(a, b | c)", "test").unwrap_err();
2846 assert!(format!("{}", err).contains("cannot mix"));
2847 }
2848
2849 #[test]
2850 fn pred_eq_with_ident_rhs_rejected_with_in_hint() {
2851 let err = parse_predicate("subject.type == allowed_types").unwrap_err();
2854 let msg = format!("{}", err);
2855 assert!(msg.contains("RHS-as-identifier"));
2856 assert!(msg.contains("set membership use"));
2857 }
2858
2859 #[test]
2860 fn pred_in_set_basic() {
2861 let e = parse_predicate("subject.type in allowed_types").unwrap();
2862 assert_eq!(
2863 e,
2864 Expression::Condition(Condition::InSet {
2865 value_key: "subject.type".into(),
2866 set_key: "allowed_types".into(),
2867 negate: false,
2868 }),
2869 );
2870 }
2871
2872 #[test]
2873 fn pred_not_in_set() {
2874 let e = parse_predicate("subject.type not in blocked_types").unwrap();
2875 assert_eq!(
2876 e,
2877 Expression::Condition(Condition::InSet {
2878 value_key: "subject.type".into(),
2879 set_key: "blocked_types".into(),
2880 negate: true,
2881 }),
2882 );
2883 }
2884
2885 #[test]
2886 fn pred_exists_basic() {
2887 let e = parse_predicate("exists(args.amount)").unwrap();
2888 assert_eq!(
2889 e,
2890 Expression::Condition(Condition::Exists {
2891 key: "args.amount".into()
2892 }),
2893 );
2894 }
2895
2896 #[test]
2897 fn pred_exists_inside_compound() {
2898 let e = parse_predicate("exists(args.amount) & args.amount > 0").unwrap();
2900 match e {
2901 Expression::And(parts) => {
2902 assert_eq!(parts.len(), 2);
2903 assert_eq!(
2904 parts[0],
2905 Expression::Condition(Condition::Exists {
2906 key: "args.amount".into()
2907 }),
2908 );
2909 },
2910 other => panic!("expected And, got {:?}", other),
2911 }
2912 }
2913
2914 #[test]
2915 fn pred_exists_requires_paren_and_ident() {
2916 assert!(parse_predicate("exists").is_err());
2917 assert!(parse_predicate("exists()").is_err());
2918 assert!(parse_predicate("exists(authenticated").is_err());
2919 }
2920
2921 #[test]
2922 fn pred_trailing_tokens_rejected() {
2923 let err = parse_predicate("a b").unwrap_err();
2924 assert!(format!("{}", err).contains("trailing"));
2925 }
2926
2927 #[test]
2930 fn rule_predicate_action_form() {
2931 let r = parse_rule("delegation.depth > 2: deny", "test").unwrap();
2932 match r.effects.as_slice() {
2933 [Effect::Deny { .. }] => {},
2934 other => panic!("expected [Deny], got {:?}", other),
2935 }
2936 match r.condition {
2937 Expression::Condition(Condition::Comparison { .. }) => {},
2938 other => panic!("expected Comparison, got {:?}", other),
2939 }
2940 }
2941
2942 #[test]
2943 fn rule_predicate_only_defaults_to_deny() {
2944 let r = parse_rule("!authenticated", "test").unwrap();
2946 assert!(matches!(r.effects.as_slice(), [Effect::Deny { .. }]));
2947 }
2948
2949 #[test]
2950 fn rule_explicit_allow() {
2951 let r = parse_rule("role.admin: allow", "test").unwrap();
2952 assert!(matches!(r.effects.as_slice(), [Effect::Allow]));
2953 }
2954
2955 #[test]
2956 fn rule_bare_action_unconditional() {
2957 let r = parse_rule("deny", "test").unwrap();
2960 assert_eq!(r.condition, Expression::Always);
2961 assert!(matches!(
2962 r.effects.as_slice(),
2963 [Effect::Deny {
2964 reason: None,
2965 code: None
2966 }]
2967 ));
2968
2969 let r = parse_rule("allow", "test").unwrap();
2970 assert_eq!(r.condition, Expression::Always);
2971 assert!(matches!(r.effects.as_slice(), [Effect::Allow]));
2972 }
2973
2974 #[test]
2975 fn rule_bare_deny_call_carries_reason_and_code() {
2976 let r = parse_rule("deny('nope')", "test").unwrap();
2980 assert_eq!(r.condition, Expression::Always);
2981 match r.effects.as_slice() {
2982 [Effect::Deny {
2983 reason: Some(reason),
2984 code: None,
2985 }] => assert_eq!(reason, "nope"),
2986 other => panic!(
2987 "expected [Deny{{reason: Some, code: None}}], got {:?}",
2988 other
2989 ),
2990 }
2991
2992 let r = parse_rule("deny('nope', 'cel.policy')", "test").unwrap();
2993 assert_eq!(r.condition, Expression::Always);
2994 match r.effects.as_slice() {
2995 [Effect::Deny {
2996 reason: Some(reason),
2997 code: Some(code),
2998 }] => {
2999 assert_eq!(reason, "nope");
3000 assert_eq!(code, "cel.policy");
3001 },
3002 other => panic!("expected [Deny{{reason, code}}], got {:?}", other),
3003 }
3004 }
3005
3006 #[test]
3007 fn rule_malformed_bare_deny_call_errors() {
3008 let err = parse_rule("deny(unquoted)", "test").unwrap_err();
3011 assert!(
3012 matches!(err, ParseError::Rule { .. }),
3013 "expected ParseError::Rule, got {:?}",
3014 err
3015 );
3016 }
3017
3018 #[test]
3019 fn rule_step_kinds_rejected_clearly() {
3020 for s in [
3021 "plugin(rate_limiter)",
3022 "cedar:(action: read)",
3023 "opa(path)",
3024 "taint(audit)",
3025 ] {
3026 let err = parse_rule(s, "test").unwrap_err();
3027 assert!(
3028 matches!(err, ParseError::UnsupportedStep { .. }),
3029 "expected UnsupportedStep for `{}`, got {:?}",
3030 s,
3031 err
3032 );
3033 }
3034 }
3035
3036 #[test]
3037 fn rule_deny_with_unquoted_arg_rejected() {
3038 let err = parse_rule(r#"authenticated: deny "go away""#, "test").unwrap_err();
3043 assert!(format!("{}", err).contains("unsupported action"));
3044 }
3045
3046 #[test]
3047 fn rule_deny_with_quoted_reason_accepted() {
3048 let r = parse_rule(r#"delegation.depth > 2: deny('too deep')"#, "test").unwrap();
3051 assert!(matches!(
3052 r.effects.as_slice(),
3053 [Effect::Deny { reason: Some(s), code: None }] if s == "too deep"
3054 ));
3055 }
3056
3057 #[test]
3058 fn rule_deny_with_reason_and_code_accepted() {
3059 let r = parse_rule(
3062 r#"delegation.depth > 2: deny('too deep', 'delegation.depth_exceeded')"#,
3063 "test",
3064 )
3065 .unwrap();
3066 match r.effects.as_slice() {
3067 [Effect::Deny {
3068 reason: Some(reason),
3069 code: Some(code),
3070 }] => {
3071 assert_eq!(reason, "too deep");
3072 assert_eq!(code, "delegation.depth_exceeded");
3073 },
3074 other => panic!("expected Deny with reason+code, got {:?}", other),
3075 }
3076 }
3077
3078 #[test]
3079 fn rule_deny_with_too_many_args_rejected() {
3080 let err = parse_rule(r#"x: deny('a', 'b', 'c')"#, "test").unwrap_err();
3082 assert!(format!("{}", err).contains("at most two args"));
3083 }
3084
3085 #[test]
3086 fn rule_deny_with_unquoted_args_in_call_rejected() {
3087 let err = parse_rule(r#"x: deny(bare, identifier)"#, "test").unwrap_err();
3089 assert!(format!("{}", err).contains("expected a quoted string"));
3090 }
3091
3092 fn parse_step_yaml(yaml: &str) -> Result<Step, ParseError> {
3095 let v: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
3096 parse_step(&v, "test")
3097 }
3098
3099 #[test]
3100 fn when_do_single_effect_deny() {
3101 let step = parse_step_yaml("when: delegation.depth > 2\ndo: deny").unwrap();
3103 match step {
3104 Step::Rule(rule) => {
3105 assert!(matches!(
3106 rule.condition,
3107 Expression::Condition(Condition::Comparison { .. })
3108 ));
3109 assert!(matches!(
3110 rule.effects.as_slice(),
3111 [Effect::Deny {
3112 reason: None,
3113 code: None
3114 }]
3115 ));
3116 },
3117 other => panic!("expected Step::Rule, got {:?}", other),
3118 }
3119 }
3120
3121 #[test]
3122 fn when_do_single_effect_deny_with_reason_and_code() {
3123 let step = parse_step_yaml(
3125 "when: delegation.depth > 2\ndo: deny('too deep', 'delegation.depth_exceeded')",
3126 )
3127 .unwrap();
3128 let Step::Rule(rule) = step else {
3129 panic!("expected Step::Rule");
3130 };
3131 match rule.effects.as_slice() {
3132 [Effect::Deny {
3133 reason: Some(r),
3134 code: Some(c),
3135 }] => {
3136 assert_eq!(r, "too deep");
3137 assert_eq!(c, "delegation.depth_exceeded");
3138 },
3139 other => panic!("expected Deny+reason+code, got {:?}", other),
3140 }
3141 }
3142
3143 #[test]
3144 fn when_do_multi_effect_list() {
3145 let yaml = r#"
3148when: "!role.hr"
3149do:
3150 - "plugin(audit_logger)"
3151 - "taint(unauth, session)"
3152 - "deny('refused', 'role.hr_required')"
3153"#;
3154 let step = parse_step_yaml(yaml).unwrap();
3155 let Step::Rule(rule) = step else {
3156 panic!("expected Step::Rule");
3157 };
3158 assert_eq!(rule.effects.len(), 3);
3159 assert!(matches!(rule.effects[0], Effect::Plugin { ref name } if name == "audit_logger"));
3160 assert!(matches!(
3161 rule.effects[1],
3162 Effect::Taint { ref label, .. } if label == "unauth"
3163 ));
3164 match &rule.effects[2] {
3165 Effect::Deny {
3166 reason: Some(r),
3167 code: Some(c),
3168 } => {
3169 assert_eq!(r, "refused");
3170 assert_eq!(c, "role.hr_required");
3171 },
3172 other => panic!("expected Deny+reason+code, got {:?}", other),
3173 }
3174 }
3175
3176 #[test]
3177 fn when_do_key_order_does_not_matter() {
3178 let step = parse_step_yaml("do: deny\nwhen: delegation.depth > 2").unwrap();
3180 assert!(matches!(step, Step::Rule(_)));
3181 }
3182
3183 #[test]
3184 fn when_do_with_unknown_key_rejected() {
3185 let err = parse_step_yaml("when: x\ndo: deny\nwhne: typo").unwrap_err();
3187 assert!(format!("{}", err).contains("unexpected key"));
3188 }
3189
3190 #[test]
3191 fn when_do_empty_do_list_rejected() {
3192 let err = parse_step_yaml("when: x\ndo: []").unwrap_err();
3195 assert!(format!("{}", err).contains("no effects"));
3196 }
3197
3198 #[test]
3201 fn shorthand_multi_effect_map() {
3202 let yaml = r#"
3205"!role.hr":
3206 - "plugin(audit_logger)"
3207 - "deny('unauthorized')"
3208"#;
3209 let step = parse_step_yaml(yaml).unwrap();
3210 let Step::Rule(rule) = step else {
3211 panic!("expected Step::Rule");
3212 };
3213 assert_eq!(rule.effects.len(), 2);
3214 assert!(matches!(rule.effects[0], Effect::Plugin { ref name } if name == "audit_logger"));
3215 assert!(matches!(
3216 rule.effects[1],
3217 Effect::Deny { reason: Some(ref r), code: None } if r == "unauthorized"
3218 ));
3219 }
3220
3221 #[test]
3222 fn shorthand_multi_effect_map_with_nested_delegate() {
3223 let yaml = r#"
3226"role.hr":
3227 - delegate:
3228 plugin: workday-oauth
3229 config:
3230 audience: workday-api
3231 - "plugin(audit_logger)"
3232"#;
3233 let step = parse_step_yaml(yaml).unwrap();
3234 let Step::Rule(rule) = step else {
3235 panic!("expected Step::Rule");
3236 };
3237 assert_eq!(rule.effects.len(), 2);
3238 assert!(matches!(rule.effects[0], Effect::Delegate(_)));
3239 assert!(matches!(rule.effects[1], Effect::Plugin { .. }));
3240 }
3241
3242 #[test]
3243 fn cedar_with_list_body_still_parses_as_pdp() {
3244 let err = parse_step_yaml("cedar: [oh no]").unwrap_err();
3255 assert!(format!("{}", err).contains("body must be a map"));
3258 }
3259
3260 #[test]
3261 fn shorthand_multi_effect_empty_list_rejected() {
3262 let err = parse_step_yaml(r#""x": []"#).unwrap_err();
3263 assert!(format!("{}", err).contains("no effects"));
3264 }
3265
3266 #[test]
3269 fn when_do_with_field_op_result_redact() {
3270 let yaml = r#"
3273when: "!perm.view_ssn"
3274do:
3275 - "plugin(audit_logger)"
3276 - "result.salary | redact"
3277"#;
3278 let step = parse_step_yaml(yaml).unwrap();
3279 let Step::Rule(rule) = step else {
3280 panic!("expected Step::Rule");
3281 };
3282 assert_eq!(rule.effects.len(), 2);
3283 assert!(matches!(rule.effects[0], Effect::Plugin { .. }));
3284 match &rule.effects[1] {
3285 Effect::FieldOp { path, stages } => {
3286 assert_eq!(path, "result.salary");
3287 assert_eq!(stages.len(), 1, "single `redact` stage");
3288 },
3289 other => panic!("expected FieldOp, got {:?}", other),
3290 }
3291 }
3292
3293 #[test]
3294 fn when_do_with_field_op_args_mask() {
3295 let yaml = r#"
3297when: role.support
3298do: "args.card_number | mask(4)"
3299"#;
3300 let step = parse_step_yaml(yaml).unwrap();
3301 let Step::Rule(rule) = step else {
3302 panic!("expected Step::Rule");
3303 };
3304 match &rule.effects[..] {
3305 [Effect::FieldOp { path, stages }] => {
3306 assert_eq!(path, "args.card_number");
3307 assert_eq!(stages.len(), 1);
3308 },
3309 other => panic!("expected single FieldOp, got {:?}", other),
3310 }
3311 }
3312
3313 #[test]
3314 fn when_do_with_chained_field_op() {
3315 let yaml = r#"
3318when: role.support
3319do: "args.card_number | str | mask(4)"
3320"#;
3321 let step = parse_step_yaml(yaml).unwrap();
3322 let Step::Rule(rule) = step else {
3323 panic!("expected Step::Rule");
3324 };
3325 match &rule.effects[..] {
3326 [Effect::FieldOp { path, stages }] => {
3327 assert_eq!(path, "args.card_number");
3328 assert_eq!(stages.len(), 2, "two-stage chain");
3329 },
3330 other => panic!("expected single FieldOp, got {:?}", other),
3331 }
3332 }
3333
3334 #[test]
3335 fn field_stage_run_aliases_plugin() {
3336 let yaml = r#"
3339when: role.support
3340do: "args.card_number | run(luhn)"
3341"#;
3342 let step = parse_step_yaml(yaml).unwrap();
3343 let Step::Rule(rule) = step else {
3344 panic!("expected Step::Rule");
3345 };
3346 match &rule.effects[..] {
3347 [Effect::FieldOp { path, stages }] => {
3348 assert_eq!(path, "args.card_number");
3349 match &stages[..] {
3350 [Stage::Plugin { name }] => assert_eq!(name, "luhn"),
3351 other => panic!("expected [Stage::Plugin], got {:?}", other),
3352 }
3353 },
3354 other => panic!("expected single FieldOp, got {:?}", other),
3355 }
3356 }
3357
3358 #[test]
3359 fn field_stage_plugin_empty_name_is_rejected() {
3360 for verb in ["plugin", "run"] {
3365 let err = parse_stage(&format!("{verb}()")).expect_err("empty name must error");
3366 let msg = format!("{err}");
3367 assert!(
3368 msg.contains(verb) && msg.contains("must not be empty"),
3369 "{verb}(): expected verb-named empty-name error, got: {msg}"
3370 );
3371 }
3372 }
3373
3374 #[test]
3375 fn field_op_invalid_path_falls_through() {
3376 let yaml = r#"do: "role.hr | redact""#;
3381 let _ = parse_step_yaml(&format!("when: true\n{}", yaml));
3382 let step = parse_step_yaml("when: true\ndo: \"role.hr | redact\"");
3387 match step {
3388 Ok(Step::Rule(rule)) => {
3389 assert!(
3390 !matches!(rule.effects.as_slice(), [Effect::FieldOp { .. }]),
3391 "bare `role.hr` must NOT parse as a FieldOp path"
3392 );
3393 },
3394 Err(_) => {}, other => panic!("unexpected: {:?}", other),
3396 }
3397 }
3398
3399 #[test]
3400 fn field_op_empty_chain_rejected() {
3401 let yaml = r#"when: true
3403do: "args.x | ""#;
3404 let _ = parse_step_yaml(yaml); }
3406
3407 #[test]
3408 fn shorthand_multi_effect_with_field_op() {
3409 let yaml = r#"
3411"!perm.view_ssn":
3412 - "plugin(audit_logger)"
3413 - "result.ssn | redact"
3414"#;
3415 let step = parse_step_yaml(yaml).unwrap();
3416 let Step::Rule(rule) = step else {
3417 panic!("expected Step::Rule");
3418 };
3419 assert_eq!(rule.effects.len(), 2);
3420 assert!(matches!(rule.effects[1], Effect::FieldOp { .. }));
3421 }
3422
3423 #[test]
3424 fn find_top_level_pipe_skips_inside_parens() {
3425 assert_eq!(find_top_level_pipe("args.x | mask(4)"), Some(7));
3428 assert_eq!(find_top_level_pipe("validate(luhn)"), None);
3429 assert_eq!(find_top_level_pipe(r#"args.x | mask("a|b")"#), Some(7));
3430 assert_eq!(find_top_level_pipe("mask(a|b)"), None);
3432 }
3433
3434 #[test]
3437 fn top_level_sequential() {
3438 let yaml = r#"
3440sequential:
3441 - "plugin(rate_limiter)"
3442 - "plugin(audit_logger)"
3443"#;
3444 let step = parse_step_yaml(yaml).unwrap();
3445 let Step::Rule(rule) = step else {
3446 panic!("expected Rule");
3447 };
3448 assert!(matches!(rule.condition, Expression::Always));
3449 match rule.effects.as_slice() {
3450 [Effect::Sequential(inner)] => {
3451 assert_eq!(inner.len(), 2);
3452 assert!(matches!(inner[0], Effect::Plugin { .. }));
3453 assert!(matches!(inner[1], Effect::Plugin { .. }));
3454 },
3455 other => panic!("expected single Sequential effect, got {:?}", other),
3456 }
3457 }
3458
3459 #[test]
3460 fn top_level_parallel() {
3461 let yaml = r#"
3462parallel:
3463 - "plugin(pii_scanner)"
3464 - "plugin(nemo_guardrails)"
3465"#;
3466 let step = parse_step_yaml(yaml).unwrap();
3467 let Step::Rule(rule) = step else {
3468 panic!("expected Rule");
3469 };
3470 match rule.effects.as_slice() {
3471 [Effect::Parallel(inner)] => {
3472 assert_eq!(inner.len(), 2);
3473 },
3474 other => panic!("expected single Parallel effect, got {:?}", other),
3475 }
3476 }
3477
3478 #[test]
3479 fn parallel_inside_do_body() {
3480 let yaml = r#"
3483when: args.include_ssn == true
3484do:
3485 parallel:
3486 - "plugin(pii_scanner)"
3487 - "plugin(nemo_guardrails)"
3488"#;
3489 let step = parse_step_yaml(yaml).unwrap();
3490 let Step::Rule(rule) = step else {
3491 panic!("expected Rule");
3492 };
3493 match rule.effects.as_slice() {
3494 [Effect::Parallel(inner)] => assert_eq!(inner.len(), 2),
3495 other => panic!("expected Parallel in do:, got {:?}", other),
3496 }
3497 }
3498
3499 #[test]
3500 fn parallel_rejects_field_op_at_parse_time() {
3501 let yaml = r#"
3503parallel:
3504 - "plugin(audit)"
3505 - "args.ssn | redact"
3506"#;
3507 let err = parse_step_yaml(yaml).unwrap_err();
3508 assert!(format!("{}", err).contains("mutation"), "got: {}", err);
3509 }
3510
3511 #[test]
3512 fn parallel_rejects_delegate_at_parse_time() {
3513 let yaml = r#"
3514parallel:
3515 - "plugin(audit)"
3516 - "delegate(workday)"
3517"#;
3518 let err = parse_step_yaml(yaml).unwrap_err();
3519 assert!(format!("{}", err).contains("mutation"));
3520 }
3521
3522 #[test]
3523 fn sequential_allows_mutations() {
3524 let yaml = r#"
3526sequential:
3527 - "args.ssn | redact"
3528 - "plugin(audit)"
3529"#;
3530 let step = parse_step_yaml(yaml).unwrap();
3531 let Step::Rule(rule) = step else {
3532 panic!("expected Rule")
3533 };
3534 match rule.effects.as_slice() {
3535 [Effect::Sequential(inner)] => {
3536 assert!(matches!(inner[0], Effect::FieldOp { .. }));
3537 assert!(matches!(inner[1], Effect::Plugin { .. }));
3538 },
3539 other => panic!("got {:?}", other),
3540 }
3541 }
3542
3543 #[test]
3544 fn parallel_empty_list_rejected() {
3545 let err = parse_step_yaml("parallel: []").unwrap_err();
3546 assert!(format!("{}", err).contains("empty"));
3547 }
3548
3549 #[test]
3550 fn sequential_empty_list_rejected() {
3551 let err = parse_step_yaml("sequential: []").unwrap_err();
3552 assert!(format!("{}", err).contains("empty"));
3553 }
3554
3555 #[test]
3556 fn nested_orchestration() {
3557 let yaml = r#"
3560sequential:
3561 - "plugin(rate_limiter)"
3562 - parallel:
3563 - "plugin(pii_scanner)"
3564 - "plugin(nemo)"
3565"#;
3566 let step = parse_step_yaml(yaml).unwrap();
3567 let Step::Rule(rule) = step else {
3568 panic!("expected Rule")
3569 };
3570 let Effect::Sequential(outer) = &rule.effects[0] else {
3571 panic!("expected Sequential");
3572 };
3573 assert_eq!(outer.len(), 2);
3574 assert!(matches!(outer[0], Effect::Plugin { .. }));
3575 match &outer[1] {
3576 Effect::Parallel(inner) => assert_eq!(inner.len(), 2),
3577 other => panic!("expected nested Parallel, got {:?}", other),
3578 }
3579 }
3580
3581 #[test]
3584 fn split_respects_quotes_and_parens() {
3585 let r = parse_rule(r#"session.labels contains "a:b": deny"#, "test").unwrap();
3587 assert!(matches!(r.effects.as_slice(), [Effect::Deny { .. }]));
3588 if let Expression::Condition(Condition::Comparison { value, .. }) = r.condition {
3589 assert_eq!(value, Literal::String("a:b".into()));
3590 } else {
3591 panic!("expected Comparison");
3592 }
3593 }
3594
3595 #[test]
3598 fn compile_simple_route() {
3599 let yaml = r#"
3600routes:
3601 get_compensation:
3602 pre_invocation:
3603 - "require(authenticated)"
3604 - "require(role.hr | role.finance)"
3605 - "delegation.depth > 2 & include_ssn: deny"
3606"#;
3607 let routes = compile_config(yaml).unwrap().routes;
3608 let route = routes.get("get_compensation").expect("route missing");
3609 assert_eq!(route.policy.len(), 3);
3610 assert!(route
3611 .declared_phases()
3612 .contains(crate::rules::Phase::Policy));
3613 }
3614
3615 #[test]
3616 fn authorization_nested_and_flat_forms_are_equivalent() {
3617 let nested = r#"
3621routes:
3622 r:
3623 authorization:
3624 pre_invocation:
3625 - "require(authenticated)"
3626 post_invocation:
3627 - "taint(audit, session)"
3628"#;
3629 let flat = r#"
3630routes:
3631 r:
3632 pre_invocation:
3633 - "require(authenticated)"
3634 post_invocation:
3635 - "taint(audit, session)"
3636"#;
3637 let a = compile_config(nested).unwrap().routes;
3638 let b = compile_config(flat).unwrap().routes;
3639 let ra = a.get("r").expect("nested route");
3640 let rb = b.get("r").expect("flat route");
3641 assert_eq!(ra.policy.len(), rb.policy.len());
3642 assert_eq!(ra.post_policy.len(), rb.post_policy.len());
3643 assert_eq!(ra.policy.len(), 1);
3644 assert_eq!(ra.post_policy.len(), 1);
3645 }
3646
3647 #[test]
3648 fn legacy_key_nested_under_authorization_is_rejected() {
3649 let yaml = r#"
3654routes:
3655 r:
3656 authorization:
3657 policy:
3658 - "require(authenticated)"
3659"#;
3660 let err = compile_config(yaml).expect_err("nested legacy `policy:` must be rejected");
3661 let msg = format!("{err}");
3662 assert!(
3663 msg.contains("policy") || msg.contains("unknown field"),
3664 "error should flag the unknown/legacy nested key: {msg}"
3665 );
3666 }
3667
3668 #[test]
3669 fn authorization_typo_under_wrapper_is_rejected() {
3670 let yaml = r#"
3673routes:
3674 r:
3675 authorization:
3676 pre_invocaton:
3677 - "require(authenticated)"
3678"#;
3679 assert!(
3680 compile_config(yaml).is_err(),
3681 "a typo'd sub-key under `authorization:` must be rejected, not ignored"
3682 );
3683 }
3684
3685 #[test]
3686 fn same_phase_declared_nested_and_flat_is_rejected() {
3687 let yaml = r#"
3690routes:
3691 r:
3692 authorization:
3693 pre_invocation:
3694 - "require(authenticated)"
3695 pre_invocation:
3696 - "require(role.hr)"
3697"#;
3698 let err = compile_config(yaml).expect_err("both-forms-same-section must be rejected");
3699 assert!(
3700 matches!(err, ParseError::ConflictingAuthorizationForms { ref phase, .. } if phase == "pre_invocation"),
3701 "expected ConflictingAuthorizationForms for pre_invocation, got {err:?}"
3702 );
3703 }
3704
3705 #[test]
3706 fn field_pipeline_error_names_field_path() {
3707 let yaml = r#"
3710routes:
3711 r:
3712 result:
3713 x: "nonsense"
3714"#;
3715 let err = compile_config(yaml).unwrap_err();
3716 let msg = format!("{err}");
3717 assert!(msg.contains("result.x"), "expected result.x in: {msg}");
3718 }
3719
3720 #[test]
3721 fn legacy_policy_field_names_are_rejected() {
3722 for (old, hint) in [
3725 ("policy", "pre_invocation"),
3726 ("post_policy", "post_invocation"),
3727 ] {
3728 let yaml = format!("routes:\n r:\n {old}:\n - \"require(authenticated)\"\n");
3729 let err = compile_config(&yaml).expect_err(&format!("legacy `{old}` must be rejected"));
3730 let msg = format!("{err}");
3731 assert!(
3732 msg.contains(old) && msg.contains(hint),
3733 "`{old}` rejection should name the replacement `{hint}`: {msg}"
3734 );
3735 }
3736 }
3737
3738 #[test]
3739 fn legacy_only_route_is_not_silently_omitted() {
3740 let yaml = r#"
3744routes:
3745 ghost:
3746 policy:
3747 - "require(authenticated)"
3748"#;
3749 assert!(
3750 matches!(compile_config(yaml), Err(ParseError::RenamedField { .. })),
3751 "legacy-only route must be rejected, not omitted"
3752 );
3753 }
3754
3755 #[test]
3756 fn compile_omits_routes_without_apl_blocks() {
3757 let yaml = r#"
3763routes:
3764 legacy:
3765 priority: 50
3766 apl_route:
3767 pre_invocation:
3768 - "require(authenticated)"
3769"#;
3770 let routes = compile_config(yaml).unwrap().routes;
3771 assert!(routes.contains_key("apl_route"));
3772 assert!(
3773 !routes.contains_key("legacy"),
3774 "legacy route should be omitted, not compiled"
3775 );
3776 }
3777
3778 #[test]
3779 fn compile_unknown_top_level_keys_ignored() {
3780 let yaml = r#"
3781version: "0.1"
3782policy_evaluator:
3783 kind: apl
3784plugins:
3785 - name: rate_limiter
3786 kind: native
3787imports:
3788 - "./shared.yaml"
3789routes:
3790 ping:
3791 pre_invocation:
3792 - "require(authenticated)"
3793"#;
3794 let routes = compile_config(yaml).unwrap().routes;
3795 assert!(routes.contains_key("ping"));
3796 }
3797
3798 #[test]
3799 fn compile_propagates_rule_errors_with_source() {
3800 let yaml = r#"
3801routes:
3802 bad:
3803 pre_invocation:
3804 - "subject.id == garbage_ident"
3805"#;
3806 let err = compile_config(yaml).unwrap_err();
3807 let msg = format!("{}", err);
3809 assert!(
3810 msg.contains("RHS-as-identifier") || msg.contains("garbage_ident"),
3811 "error message should reference the failure: {}",
3812 msg,
3813 );
3814 }
3815
3816 #[test]
3817 fn compile_plugin_step_string_form() {
3818 let yaml = r#"
3819routes:
3820 rate_limited:
3821 pre_invocation:
3822 - "plugin(rate_limiter)"
3823"#;
3824 let routes = compile_config(yaml).unwrap().routes;
3825 let route = routes.get("rate_limited").unwrap();
3826 assert_eq!(route.policy.len(), 1);
3827 match &route.policy[0] {
3828 Effect::Plugin { name } => assert_eq!(name, "rate_limiter"),
3829 other => panic!("expected Effect::Plugin, got {:?}", other),
3830 }
3831 }
3832
3833 #[test]
3834 fn compile_run_step_string_form_aliases_plugin() {
3835 let yaml = r#"
3838routes:
3839 rate_limited:
3840 pre_invocation:
3841 - "run(rate_limiter)"
3842"#;
3843 let routes = compile_config(yaml).unwrap().routes;
3844 let route = routes.get("rate_limited").unwrap();
3845 assert_eq!(route.policy.len(), 1);
3846 match &route.policy[0] {
3847 Effect::Plugin { name } => assert_eq!(name, "rate_limiter"),
3848 other => panic!("expected Effect::Plugin, got {:?}", other),
3849 }
3850 }
3851
3852 #[test]
3853 fn parse_step_run_is_plugin_alias() {
3854 for s in ["run(audit-log)", "plugin(audit-log)"] {
3855 let step = parse_step(&serde_yaml::Value::String(s.to_string()), "test").unwrap();
3856 match step {
3857 crate::step::Step::Plugin { name } => assert_eq!(name, "audit-log", "{s}"),
3858 other => panic!("expected Step::Plugin for `{s}`, got {other:?}"),
3859 }
3860 }
3861 let err = parse_step(&serde_yaml::Value::String("run()".to_string()), "test").unwrap_err();
3863 assert!(
3864 format!("{err}").contains("run("),
3865 "error should name `run(...)`: {err}"
3866 );
3867 }
3868
3869 #[test]
3870 fn compile_taint_step_string_form() {
3871 let yaml = r#"
3872routes:
3873 audit_marked:
3874 pre_invocation:
3875 - "taint(audit, session)"
3876"#;
3877 let routes = compile_config(yaml).unwrap().routes;
3878 let route = routes.get("audit_marked").unwrap();
3879 match &route.policy[0] {
3880 Effect::Taint { label, scopes } => {
3881 assert_eq!(label, "audit");
3882 assert_eq!(scopes, &vec![TaintScope::Session]);
3883 },
3884 other => panic!("expected Effect::Taint, got {:?}", other),
3885 }
3886 }
3887
3888 #[test]
3889 fn compile_pdp_call_cedar_map_form() {
3890 let yaml = r#"
3892routes:
3893 authz_check:
3894 pre_invocation:
3895 - cedar:
3896 action: read
3897 resource: employee
3898 on_deny:
3899 - deny
3900 on_allow:
3901 - "plugin(audit_logger)"
3902"#;
3903 let routes = compile_config(yaml).unwrap().routes;
3904 let route = routes.get("authz_check").unwrap();
3905 match &route.policy[0] {
3906 Effect::Pdp {
3907 call,
3908 on_deny,
3909 on_allow,
3910 } => {
3911 assert_eq!(call.dialect, PdpDialect::Cedar);
3912 let args_map = call.args.as_mapping().expect("cedar args should be a map");
3915 assert!(args_map.contains_key(serde_yaml::Value::String("action".into())));
3916 assert!(args_map.contains_key(serde_yaml::Value::String("resource".into())));
3917 assert!(!args_map.contains_key(serde_yaml::Value::String("on_deny".into())));
3918 assert_eq!(on_deny.len(), 1);
3919 assert_eq!(on_allow.len(), 1);
3920 },
3921 other => panic!("expected Effect::Pdp, got {:?}", other),
3922 }
3923 }
3924
3925 #[test]
3926 fn compile_pdp_call_cel_map_form() {
3927 let yaml = r#"
3930routes:
3931 authz_check:
3932 pre_invocation:
3933 - cel:
3934 expr: "subject.id == 'alice' && delegation.depth <= 2"
3935 on_deny:
3936 - deny
3937"#;
3938 let routes = compile_config(yaml).unwrap().routes;
3939 let route = routes.get("authz_check").unwrap();
3940 match &route.policy[0] {
3941 Effect::Pdp {
3942 call,
3943 on_deny,
3944 on_allow,
3945 } => {
3946 assert_eq!(call.dialect, PdpDialect::Cel);
3947 let args_map = call.args.as_mapping().expect("cel args should be a map");
3948 assert!(args_map.contains_key(serde_yaml::Value::String("expr".into())));
3949 assert!(!args_map.contains_key(serde_yaml::Value::String("on_deny".into())));
3951 assert_eq!(on_deny.len(), 1);
3952 assert_eq!(on_allow.len(), 0);
3953 },
3954 other => panic!("expected Effect::Pdp, got {:?}", other),
3955 }
3956 }
3957
3958 #[test]
3959 fn compile_pdp_call_opa_paren_form() {
3960 let yaml = r#"
3962routes:
3963 opa_check:
3964 pre_invocation:
3965 - 'opa("hr/compensation/deny"):':
3966 on_deny:
3967 - deny
3968"#;
3969 let routes = compile_config(yaml).unwrap().routes;
3970 let route = routes.get("opa_check").unwrap();
3971 match &route.policy[0] {
3972 Effect::Pdp { call, on_deny, .. } => {
3973 assert_eq!(call.dialect, PdpDialect::Opa);
3974 assert!(call.args.as_str().unwrap().contains("hr/compensation/deny"));
3976 assert_eq!(on_deny.len(), 1);
3977 },
3978 other => panic!("expected Effect::Pdp, got {:?}", other),
3979 }
3980 }
3981
3982 #[test]
3983 fn compile_pdp_unknown_dialect_becomes_custom() {
3984 let yaml = r#"
3985routes:
3986 custom_pdp:
3987 pre_invocation:
3988 - my_engine:
3989 on_deny: [deny]
3990"#;
3991 let routes = compile_config(yaml).unwrap().routes;
3992 match &routes.get("custom_pdp").unwrap().policy[0] {
3993 Effect::Pdp { call, .. } => {
3994 assert_eq!(call.dialect, PdpDialect::Custom("my_engine".into()));
3995 },
3996 other => panic!("expected Pdp, got {:?}", other),
3997 }
3998 }
3999
4000 #[tokio::test]
4003 async fn end_to_end_hr_compensation() {
4004 let yaml = r#"
4005routes:
4006 get_compensation:
4007 pre_invocation:
4008 - "require(authenticated)"
4009 - "require(role.hr | role.finance)"
4010 - "delegation.depth > 2: deny"
4011"#;
4012 let routes = compile_config(yaml).unwrap().routes;
4013 let route = routes.get("get_compensation").unwrap();
4014
4015 let pdp: std::sync::Arc<dyn crate::PdpResolver> = std::sync::Arc::new(NullPdpResolver);
4016 let plugins: std::sync::Arc<dyn crate::PluginInvoker> =
4017 std::sync::Arc::new(NullPluginInvoker);
4018 let delegations: std::sync::Arc<dyn crate::DelegationInvoker> =
4019 std::sync::Arc::new(crate::NoopDelegationInvoker);
4020 let elicitations: std::sync::Arc<dyn crate::ElicitationInvoker> =
4021 std::sync::Arc::new(crate::NoopElicitationInvoker);
4022
4023 let mut bag = AttributeBag::new();
4025 bag.set("authenticated", true);
4026 bag.set("role.hr", true);
4027 bag.set("delegation.depth", 1_i64);
4028 assert_eq!(
4029 crate::evaluate_effects(
4030 &route.policy,
4031 &mut bag,
4032 &pdp,
4033 &plugins,
4034 &delegations,
4035 &elicitations,
4036 crate::DispatchPhase::Pre,
4037 &mut crate::route::RoutePayload::new(serde_json::Value::Null)
4038 )
4039 .await
4040 .decision,
4041 Decision::Allow,
4042 );
4043
4044 bag.set("delegation.depth", 3_i64);
4046 match crate::evaluate_effects(
4047 &route.policy,
4048 &mut bag,
4049 &pdp,
4050 &plugins,
4051 &delegations,
4052 &elicitations,
4053 crate::DispatchPhase::Pre,
4054 &mut crate::route::RoutePayload::new(serde_json::Value::Null),
4055 )
4056 .await
4057 .decision
4058 {
4059 Decision::Deny { rule_source, .. } => {
4060 assert!(
4061 rule_source.contains("pre_invocation[2]"),
4062 "expected pre_invocation[2], got {}",
4063 rule_source
4064 );
4065 },
4066 d => panic!("expected Deny, got {:?}", d),
4067 }
4068
4069 let mut bag = AttributeBag::new();
4071 bag.set("authenticated", true);
4072 bag.set("delegation.depth", 1_i64);
4073 match crate::evaluate_effects(
4074 &route.policy,
4075 &mut bag,
4076 &pdp,
4077 &plugins,
4078 &delegations,
4079 &elicitations,
4080 crate::DispatchPhase::Pre,
4081 &mut crate::route::RoutePayload::new(serde_json::Value::Null),
4082 )
4083 .await
4084 .decision
4085 {
4086 Decision::Deny { rule_source, .. } => {
4087 assert!(
4088 rule_source.contains("pre_invocation[1]"),
4089 "expected pre_invocation[1], got {}",
4090 rule_source
4091 );
4092 },
4093 d => panic!("expected Deny, got {:?}", d),
4094 }
4095 }
4096
4097 struct NullPdpResolver;
4100 #[async_trait::async_trait]
4101 impl crate::PdpResolver for NullPdpResolver {
4102 fn dialect(&self) -> crate::PdpDialect {
4103 crate::PdpDialect::Cedar
4104 }
4105 async fn evaluate(
4106 &self,
4107 _call: &crate::PdpCall,
4108 _bag: &crate::AttributeBag,
4109 ) -> Result<crate::PdpDecision, crate::PdpError> {
4110 panic!("NullPdpResolver should not be invoked in pure-rule tests");
4111 }
4112 }
4113
4114 struct NullPluginInvoker;
4115 #[async_trait::async_trait]
4116 impl crate::PluginInvoker for NullPluginInvoker {
4117 async fn invoke(
4118 &self,
4119 _name: &str,
4120 _bag: &crate::AttributeBag,
4121 _invocation: crate::PluginInvocation<'_>,
4122 ) -> Result<crate::PluginOutcome, crate::PluginError> {
4123 panic!("NullPluginInvoker should not be invoked in pure-rule tests");
4124 }
4125 }
4126
4127 #[test]
4130 fn pipeline_simple_bare_stages() {
4131 let p = parse_pipeline("str").unwrap();
4132 assert_eq!(p.stages, vec![Stage::Type(TypeCheck::Str)]);
4133
4134 let p = parse_pipeline("omit").unwrap();
4135 assert_eq!(p.stages, vec![Stage::Omit]);
4136
4137 let p = parse_pipeline("hash").unwrap();
4138 assert_eq!(p.stages, vec![Stage::Hash]);
4139 }
4140
4141 #[test]
4142 fn pipeline_chains_split_on_pipe() {
4143 let p = parse_pipeline("str | mask(4)").unwrap();
4144 assert_eq!(
4145 p.stages,
4146 vec![Stage::Type(TypeCheck::Str), Stage::Mask { keep_last: 4 },]
4147 );
4148
4149 let p = parse_pipeline("int | 0..1M").unwrap();
4150 assert_eq!(
4151 p.stages,
4152 vec![
4153 Stage::Type(TypeCheck::Int),
4154 Stage::Range {
4155 min: Some(0),
4156 max: Some(1_000_000)
4157 },
4158 ]
4159 );
4160 }
4161
4162 #[test]
4163 fn pipeline_pipe_inside_parens_does_not_split() {
4164 let p = parse_pipeline("str | redact(!perm.view_ssn | role.admin)").unwrap();
4167 assert_eq!(p.stages.len(), 2);
4168 match &p.stages[1] {
4169 Stage::Redact { condition: Some(_) } => {},
4170 other => panic!("expected Redact with condition, got {:?}", other),
4171 }
4172 }
4173
4174 #[test]
4175 fn pipeline_length_constraints() {
4176 let p = parse_pipeline("len(..500)").unwrap();
4177 assert_eq!(
4178 p.stages,
4179 vec![Stage::Length {
4180 min: None,
4181 max: Some(500)
4182 }]
4183 );
4184 let p = parse_pipeline("len(10..50)").unwrap();
4185 assert_eq!(
4186 p.stages,
4187 vec![Stage::Length {
4188 min: Some(10),
4189 max: Some(50)
4190 }]
4191 );
4192 let p = parse_pipeline("len(8..)").unwrap();
4193 assert_eq!(
4194 p.stages,
4195 vec![Stage::Length {
4196 min: Some(8),
4197 max: None
4198 }]
4199 );
4200 }
4201
4202 #[test]
4203 fn pipeline_range_with_suffixes() {
4204 let p = parse_pipeline("0..10k").unwrap();
4205 assert_eq!(
4206 p.stages,
4207 vec![Stage::Range {
4208 min: Some(0),
4209 max: Some(10_000)
4210 }]
4211 );
4212 let p = parse_pipeline("0..1M").unwrap();
4213 assert_eq!(
4214 p.stages,
4215 vec![Stage::Range {
4216 min: Some(0),
4217 max: Some(1_000_000)
4218 }]
4219 );
4220 let p = parse_pipeline("..500").unwrap();
4221 assert_eq!(
4222 p.stages,
4223 vec![Stage::Range {
4224 min: None,
4225 max: Some(500)
4226 }]
4227 );
4228 }
4229
4230 #[test]
4231 fn pipeline_enum_unquoted_and_quoted() {
4232 let p = parse_pipeline("enum(low, medium, high)").unwrap();
4233 assert_eq!(
4234 p.stages,
4235 vec![Stage::Enum {
4236 values: vec!["low".into(), "medium".into(), "high".into()],
4237 }]
4238 );
4239 let p = parse_pipeline(r#"enum("a", "b")"#).unwrap();
4240 assert_eq!(
4241 p.stages,
4242 vec![Stage::Enum {
4243 values: vec!["a".into(), "b".into()],
4244 }]
4245 );
4246 }
4247
4248 #[test]
4249 fn pipeline_redact_with_predicate_condition() {
4250 let p = parse_pipeline("str | redact(!perm.view_ssn)").unwrap();
4251 assert_eq!(p.stages.len(), 2);
4252 match &p.stages[1] {
4253 Stage::Redact {
4254 condition: Some(Expression::Not(inner)),
4255 } => match inner.as_ref() {
4256 Expression::Condition(Condition::IsTrue { key }) => {
4257 assert_eq!(key, "perm.view_ssn");
4258 },
4259 other => panic!("expected IsTrue(perm.view_ssn), got {:?}", other),
4260 },
4261 other => panic!("expected Redact with Not condition, got {:?}", other),
4262 }
4263 }
4264
4265 #[test]
4266 fn pipeline_taint_scopes() {
4267 let p = parse_pipeline("taint(PII)").unwrap();
4268 assert_eq!(
4269 p.stages,
4270 vec![Stage::Taint {
4271 label: "PII".into(),
4272 scopes: vec![TaintScope::Session],
4273 }]
4274 );
4275 let p = parse_pipeline("taint(PII, message)").unwrap();
4276 assert_eq!(
4277 p.stages,
4278 vec![Stage::Taint {
4279 label: "PII".into(),
4280 scopes: vec![TaintScope::Message],
4281 }]
4282 );
4283 let p = parse_pipeline("taint(PII, [session, message])").unwrap();
4284 assert_eq!(
4285 p.stages,
4286 vec![Stage::Taint {
4287 label: "PII".into(),
4288 scopes: vec![TaintScope::Session, TaintScope::Message],
4289 }]
4290 );
4291 }
4292
4293 #[test]
4294 fn pipeline_unknown_stage_rejected() {
4295 let err = parse_pipeline("nonsense").unwrap_err();
4296 assert!(format!("{}", err).contains("unknown stage"));
4297 }
4298
4299 #[test]
4300 fn pipeline_omit_with_args_rejected() {
4301 let err = parse_pipeline("omit(!perm.x)").unwrap_err();
4303 assert!(format!("{}", err).contains("omit takes no arguments"));
4304 }
4305
4306 #[test]
4309 fn compile_route_with_args_and_result() {
4310 let yaml = r#"
4311routes:
4312 get_compensation:
4313 args:
4314 employee_id: "uuid"
4315 amount: "int | 0..1M"
4316 result:
4317 ssn: "str | redact(!perm.view_ssn)"
4318 employee_id: "str | mask(4)"
4319 internal_notes: "omit"
4320"#;
4321 let routes = compile_config(yaml).unwrap().routes;
4322 let route = routes.get("get_compensation").expect("missing route");
4323 assert_eq!(route.args.len(), 2);
4324 assert_eq!(route.result.len(), 3);
4325
4326 let ssn = route.result.iter().find(|f| f.field == "ssn").unwrap();
4328 assert_eq!(ssn.pipeline.stages.len(), 2);
4329 assert!(matches!(
4330 ssn.pipeline.stages[0],
4331 Stage::Type(TypeCheck::Str)
4332 ));
4333 assert!(matches!(
4334 ssn.pipeline.stages[1],
4335 Stage::Redact { condition: Some(_) }
4336 ));
4337
4338 let phases = route.declared_phases();
4340 assert!(phases.contains(crate::rules::Phase::Args));
4341 assert!(phases.contains(crate::rules::Phase::Result));
4342 }
4343
4344 #[test]
4345 fn compile_route_with_only_args_still_compiles() {
4346 let yaml = r#"
4349routes:
4350 validate_only:
4351 args:
4352 employee_id: "uuid"
4353"#;
4354 let routes = compile_config(yaml).unwrap().routes;
4355 assert!(routes.contains_key("validate_only"));
4356 }
4357
4358 #[test]
4359 fn compile_propagates_pipeline_parse_errors() {
4360 let yaml = r#"
4361routes:
4362 bad:
4363 result:
4364 x: "nonsense"
4365"#;
4366 let err = compile_config(yaml).unwrap_err();
4367 assert!(format!("{}", err).contains("unknown stage"));
4368 }
4369
4370 #[test]
4373 fn compile_captures_root_plugins_block_into_registry() {
4374 let yaml = r#"
4375plugins:
4376 - name: rate_limiter
4377 kind: native
4378 hooks: [tool_pre_invoke]
4379 capabilities: [read_subject]
4380 config:
4381 max_requests: 100
4382 - name: audit
4383 kind: native
4384 hooks: [tool_post_invoke]
4385routes:
4386 get_compensation:
4387 pre_invocation:
4388 - "plugin(rate_limiter)"
4389"#;
4390 let cfg = compile_config(yaml).unwrap();
4391 assert_eq!(cfg.plugins.len(), 2);
4392 let rl = cfg.plugins.get("rate_limiter").unwrap();
4393 assert_eq!(rl.kind, "native");
4394 assert_eq!(rl.hooks, vec!["tool_pre_invoke".to_string()]);
4395 assert_eq!(rl.capabilities, vec!["read_subject".to_string()]);
4396 assert!(cfg.routes.contains_key("get_compensation"));
4398 }
4399
4400 #[test]
4401 fn compile_captures_route_level_plugin_overrides() {
4402 let yaml = r#"
4403plugins:
4404 - name: rate_limiter
4405 kind: native
4406 hooks: [tool_pre_invoke]
4407 config:
4408 max_requests: 100
4409routes:
4410 hot_path:
4411 pre_invocation:
4412 - "plugin(rate_limiter)"
4413 plugins:
4414 rate_limiter:
4415 config:
4416 max_requests: 10
4417 on_error: ignore
4418"#;
4419 let cfg = compile_config(yaml).unwrap();
4420 let route = cfg.routes.get("hot_path").unwrap();
4421 let ovr = route.plugin_overrides.get("rate_limiter").unwrap();
4422 assert_eq!(ovr.on_error.as_deref(), Some("ignore"));
4423 let cfg_yaml = ovr.config.as_ref().unwrap();
4424 assert_eq!(
4425 cfg_yaml["max_requests"],
4426 serde_yaml::from_str::<serde_yaml::Value>("10").unwrap()
4427 );
4428
4429 let eff = crate::plugin_decl::EffectivePlugin::resolve(
4431 "rate_limiter",
4432 &cfg.plugins,
4433 &route.plugin_overrides,
4434 )
4435 .unwrap();
4436 assert_eq!(eff.on_error, Some("ignore"));
4437 assert_eq!(eff.hooks, &["tool_pre_invoke".to_string()]);
4439 }
4440
4441 #[test]
4444 fn compile_policy_block_value_parses_apl_body() {
4445 let yaml = r#"
4446pre_invocation:
4447 - "require(authenticated)"
4448result:
4449 ssn: "redact(!perm.view_ssn)"
4450"#;
4451 let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
4452 let compiled =
4453 compile_policy_block_value("global.policy.all", &value).expect("compile block");
4454 assert_eq!(compiled.route_key, "global.policy.all");
4455 assert_eq!(compiled.policy.len(), 1);
4456 assert_eq!(compiled.result.len(), 1);
4457 assert_eq!(compiled.result[0].field, "ssn");
4458 }
4459
4460 #[test]
4461 fn compile_policy_block_value_null_is_empty_route() {
4462 let value = serde_yaml::Value::Null;
4463 let compiled =
4464 compile_policy_block_value("global.defaults.tool", &value).expect("compile null");
4465 assert!(compiled.declared_phases().is_empty());
4466 assert_eq!(compiled.route_key, "global.defaults.tool");
4467 }
4468
4469 #[test]
4470 fn compile_policy_block_value_threads_source_into_rule_paths() {
4471 let yaml = r#"
4472pre_invocation:
4473 - "require(authenticated)"
4474"#;
4475 let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
4476 let compiled = compile_policy_block_value("global.policies.hr", &value).expect("compile");
4477 match &compiled.policy[0] {
4478 crate::rules::Effect::When { source, .. } => {
4479 assert_eq!(source, "global.policies.hr.pre_invocation[0]");
4480 },
4481 other => panic!("expected When, got {:?}", other),
4482 }
4483 }
4484
4485 #[test]
4488 fn parse_delegate_step_with_only_plugin() {
4489 let yaml = r#"
4490- delegate:
4491 plugin: workday-oauth
4492"#;
4493 let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
4494 let entry = &value.as_sequence().unwrap()[0];
4495 let step = parse_step(entry, "test.policy[0]").expect("parse");
4496 let crate::step::Step::Delegate(ds) = step else {
4497 panic!("expected Delegate, got {step:?}");
4498 };
4499 assert_eq!(ds.plugin_name, "workday-oauth");
4500 assert!(ds.config_override.is_none());
4501 assert!(ds.on_error.is_none());
4502 assert_eq!(ds.source, "test.policy[0]");
4503 }
4504
4505 #[test]
4506 fn parse_delegate_step_with_config_and_on_error() {
4507 let yaml = r#"
4508- delegate:
4509 plugin: workday-oauth
4510 config:
4511 target: workday-api
4512 permissions: [read_compensation]
4513 on_error: deny
4514"#;
4515 let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
4516 let entry = &value.as_sequence().unwrap()[0];
4517 let step = parse_step(entry, "test.policy[1]").expect("parse");
4518 let crate::step::Step::Delegate(ds) = step else {
4519 panic!("expected Delegate, got {step:?}");
4520 };
4521 assert_eq!(ds.plugin_name, "workday-oauth");
4522 assert_eq!(ds.on_error.as_deref(), Some("deny"));
4523 let cfg = ds.config_override.as_ref().expect("config_override set");
4524 let target = cfg
4525 .as_mapping()
4526 .and_then(|m| m.get(serde_yaml::Value::String("target".into())))
4527 .and_then(|v| v.as_str());
4528 assert_eq!(target, Some("workday-api"));
4529 }
4530
4531 #[test]
4532 fn parse_delegate_step_missing_plugin_errors() {
4533 let yaml = r#"
4534- delegate:
4535 config: { target: workday-api }
4536"#;
4537 let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
4538 let entry = &value.as_sequence().unwrap()[0];
4539 let err = parse_step(entry, "test.policy[0]").expect_err("missing plugin");
4540 let msg = format!("{err}");
4541 assert!(msg.contains("requires `plugin:"), "got: {msg}");
4542 }
4543
4544 #[test]
4545 fn parse_delegate_step_empty_plugin_errors() {
4546 let yaml = r#"
4547- delegate:
4548 plugin: ""
4549"#;
4550 let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
4551 let entry = &value.as_sequence().unwrap()[0];
4552 let err = parse_step(entry, "test.policy[0]").expect_err("empty plugin");
4553 let msg = format!("{err}");
4554 assert!(msg.contains("cannot be empty"), "got: {msg}");
4555 }
4556
4557 #[test]
4558 fn parse_delegate_step_non_string_on_error_errors() {
4559 let yaml = r#"
4560- delegate:
4561 plugin: workday-oauth
4562 on_error: 42
4563"#;
4564 let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
4565 let entry = &value.as_sequence().unwrap()[0];
4566 let err = parse_step(entry, "test.policy[0]").expect_err("non-string on_error");
4567 let msg = format!("{err}");
4568 assert!(msg.contains("on_error"), "got: {msg}");
4569 }
4570
4571 #[test]
4572 fn parse_delegate_step_non_map_body_errors() {
4573 let yaml = r#"
4574- delegate: workday-oauth
4575"#;
4576 let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
4577 let entry = &value.as_sequence().unwrap()[0];
4578 let err = parse_step(entry, "test.policy[0]").expect_err("non-map delegate body");
4579 let msg = format!("{err}");
4580 assert!(msg.contains("must be a map"), "got: {msg}");
4581 }
4582
4583 #[test]
4586 fn parse_delegate_string_bare_plugin_name() {
4587 let yaml = r#"- "delegate(workday-oauth)""#;
4588 let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
4589 let entry = &value.as_sequence().unwrap()[0];
4590 let step = parse_step(entry, "test.policy[0]").expect("parse");
4591 let crate::step::Step::Delegate(ds) = step else {
4592 panic!("expected Delegate, got {step:?}");
4593 };
4594 assert_eq!(ds.plugin_name, "workday-oauth");
4595 assert!(ds.config_override.is_none());
4596 assert!(ds.on_error.is_none());
4597 assert_eq!(ds.source, "test.policy[0]");
4598 }
4599
4600 #[test]
4601 fn parse_delegate_string_with_string_kwargs() {
4602 let yaml =
4603 r#"- "delegate(workday-oauth, target: workday-api, audience: https://workday.com)""#;
4604 let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
4605 let entry = &value.as_sequence().unwrap()[0];
4606 let step = parse_step(entry, "test.policy[0]").expect("parse");
4607 let crate::step::Step::Delegate(ds) = step else {
4608 panic!("expected Delegate, got {step:?}");
4609 };
4610 assert_eq!(ds.plugin_name, "workday-oauth");
4611 let cfg = ds.config_override.as_ref().unwrap().as_mapping().unwrap();
4612 assert_eq!(
4613 cfg.get(serde_yaml::Value::String("target".into()))
4614 .and_then(|v| v.as_str()),
4615 Some("workday-api"),
4616 );
4617 assert_eq!(
4618 cfg.get(serde_yaml::Value::String("audience".into()))
4619 .and_then(|v| v.as_str()),
4620 Some("https://workday.com"),
4621 );
4622 }
4623
4624 #[test]
4625 fn parse_delegate_string_with_list_kwarg() {
4626 let yaml = r#"- "delegate(workday-oauth, permissions: [read_compensation, write_notes])""#;
4627 let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
4628 let entry = &value.as_sequence().unwrap()[0];
4629 let step = parse_step(entry, "test.policy[0]").expect("parse");
4630 let crate::step::Step::Delegate(ds) = step else {
4631 panic!("expected Delegate");
4632 };
4633 let cfg = ds.config_override.as_ref().unwrap().as_mapping().unwrap();
4634 let perms = cfg
4635 .get(serde_yaml::Value::String("permissions".into()))
4636 .and_then(|v| v.as_sequence())
4637 .expect("permissions sequence");
4638 let names: Vec<&str> = perms.iter().filter_map(|v| v.as_str()).collect();
4639 assert_eq!(names, vec!["read_compensation", "write_notes"]);
4640 }
4641
4642 #[test]
4643 fn parse_delegate_string_on_error_pulled_out() {
4644 let yaml = r#"- "delegate(workday-oauth, target: workday-api, on_error: continue)""#;
4645 let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
4646 let entry = &value.as_sequence().unwrap()[0];
4647 let step = parse_step(entry, "test.policy[0]").expect("parse");
4648 let crate::step::Step::Delegate(ds) = step else {
4649 panic!("expected Delegate");
4650 };
4651 assert_eq!(ds.on_error.as_deref(), Some("continue"));
4652 let cfg = ds.config_override.as_ref().unwrap().as_mapping().unwrap();
4654 assert!(
4655 cfg.get(serde_yaml::Value::String("on_error".into()))
4656 .is_none(),
4657 "on_error must not appear in config_override"
4658 );
4659 }
4660
4661 #[test]
4662 fn parse_delegate_string_quoted_plugin_name() {
4663 let yaml = r#"- 'delegate("workday-oauth")'"#;
4667 let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
4668 let entry = &value.as_sequence().unwrap()[0];
4669 let step = parse_step(entry, "test.policy[0]").expect("parse");
4670 let crate::step::Step::Delegate(ds) = step else {
4671 panic!("expected Delegate");
4672 };
4673 assert_eq!(ds.plugin_name, "workday-oauth");
4674 }
4675
4676 #[test]
4677 fn parse_delegate_string_quoted_value_preserves_internal_commas() {
4678 let yaml =
4679 r#"- 'delegate(workday-oauth, audience: "https://workday.com,backup.workday.com")'"#;
4680 let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
4681 let entry = &value.as_sequence().unwrap()[0];
4682 let step = parse_step(entry, "test.policy[0]").expect("parse");
4683 let crate::step::Step::Delegate(ds) = step else {
4684 panic!("expected Delegate");
4685 };
4686 let cfg = ds.config_override.as_ref().unwrap().as_mapping().unwrap();
4687 assert_eq!(
4688 cfg.get(serde_yaml::Value::String("audience".into()))
4689 .and_then(|v| v.as_str()),
4690 Some("https://workday.com,backup.workday.com"),
4691 );
4692 }
4693
4694 #[test]
4695 fn parse_delegate_string_empty_args_errors() {
4696 let yaml = r#"- "delegate()""#;
4697 let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
4698 let entry = &value.as_sequence().unwrap()[0];
4699 let err = parse_step(entry, "test.policy[0]").expect_err("empty args");
4700 let msg = format!("{err}");
4701 assert!(msg.contains("plugin name"), "got: {msg}");
4702 }
4703
4704 #[test]
4705 fn parse_delegate_string_plugin_kwarg_rejected() {
4706 let yaml = r#"- "delegate(workday-oauth, plugin: other-thing)""#;
4709 let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
4710 let entry = &value.as_sequence().unwrap()[0];
4711 let err = parse_step(entry, "test.policy[0]").expect_err("plugin kwarg");
4712 let msg = format!("{err}");
4713 assert!(msg.contains("positional"), "got: {msg}");
4714 }
4715
4716 #[test]
4717 fn parse_delegate_string_kwarg_missing_colon_errors() {
4718 let yaml = r#"- "delegate(workday-oauth, target workday-api)""#;
4719 let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
4720 let entry = &value.as_sequence().unwrap()[0];
4721 let err = parse_step(entry, "test.policy[0]").expect_err("missing colon");
4722 let msg = format!("{err}");
4723 assert!(msg.contains("key: value"), "got: {msg}");
4724 }
4725
4726 #[test]
4727 fn parse_delegate_string_unbalanced_brackets_errors() {
4728 let yaml = r#"- "delegate(workday-oauth, permissions: [read_compensation)""#;
4729 let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
4730 let entry = &value.as_sequence().unwrap()[0];
4731 let err = parse_step(entry, "test.policy[0]").expect_err("unbalanced");
4732 let msg = format!("{err}");
4733 assert!(
4734 msg.contains("unmatched") || msg.contains("unbalanced"),
4735 "got: {msg}"
4736 );
4737 }
4738
4739 #[test]
4740 fn compile_route_mixed_string_and_map_delegate_forms() {
4741 let yaml = r#"
4744routes:
4745 get_compensation:
4746 pre_invocation:
4747 - "require(role.hr)"
4748 - "delegate(workday-oauth, target: workday-api, permissions: [read_compensation])"
4749 - delegate:
4750 plugin: audit-receipt
4751 on_error: continue
4752 config:
4753 mode: trace
4754"#;
4755 let cfg = compile_config(yaml).expect("compile");
4756 let route = cfg.routes.get("get_compensation").expect("route");
4757 assert_eq!(route.policy.len(), 3);
4758
4759 let crate::rules::Effect::Delegate(s1) = &route.policy[1] else {
4761 panic!("expected Delegate at policy[1]");
4762 };
4763 assert_eq!(s1.plugin_name, "workday-oauth");
4764 assert!(s1.on_error.is_none());
4765
4766 let crate::rules::Effect::Delegate(s2) = &route.policy[2] else {
4768 panic!("expected Delegate at policy[2]");
4769 };
4770 assert_eq!(s2.plugin_name, "audit-receipt");
4771 assert_eq!(s2.on_error.as_deref(), Some("continue"));
4772 }
4773
4774 #[test]
4775 fn compile_route_with_delegate_in_policy_and_post_policy() {
4776 let yaml = r#"
4780routes:
4781 get_compensation:
4782 pre_invocation:
4783 - "require(role.hr)"
4784 - delegate:
4785 plugin: workday-oauth
4786 config:
4787 target: workday-api
4788 permissions: [read_compensation]
4789 - "require(authenticated)"
4790 post_invocation:
4791 - delegate:
4792 plugin: audit-biscuit
4793 on_error: continue
4794"#;
4795 let cfg = compile_config(yaml).expect("compile");
4796 let route = cfg.routes.get("get_compensation").expect("route present");
4797 assert_eq!(route.policy.len(), 3);
4798
4799 let crate::rules::Effect::Delegate(ds) = &route.policy[1] else {
4801 panic!("expected Delegate at policy[1], got {:?}", route.policy[1]);
4802 };
4803 assert_eq!(ds.plugin_name, "workday-oauth");
4804 assert_eq!(ds.source, "get_compensation.pre_invocation[1]");
4805
4806 let crate::rules::Effect::Delegate(post_ds) = &route.post_policy[0] else {
4808 panic!("expected Delegate at post_policy[0]");
4809 };
4810 assert_eq!(post_ds.plugin_name, "audit-biscuit");
4811 assert_eq!(post_ds.on_error.as_deref(), Some("continue"));
4812 assert_eq!(post_ds.source, "get_compensation.post_invocation[0]");
4813 }
4814
4815 fn parse_elicit_str(s: &str) -> crate::step::ElicitStep {
4818 let value = serde_yaml::Value::String(s.to_string());
4819 let step = parse_step(&value, "test.policy[0]").expect("parse");
4820 match step {
4821 crate::step::Step::Elicit(e) => e,
4822 other => panic!("expected Elicit, got {other:?}"),
4823 }
4824 }
4825
4826 #[test]
4827 fn parse_require_approval_full_kwargs() {
4828 let e = parse_elicit_str(
4829 "require_approval(manager-approver, from: user.manager, channel: \"ciba\", \
4830 scope: \"args.amount <= 25000\", \
4831 purpose: \"Approve salary change\", timeout: 24h)",
4832 );
4833 assert_eq!(e.kind, ElicitKind::Approval);
4834 assert_eq!(e.plugin_name, "manager-approver");
4835 assert_eq!(e.from, "user.manager");
4836 assert_eq!(e.channel.as_deref(), Some("ciba"));
4837 assert_eq!(e.scope.as_deref(), Some("args.amount <= 25000"));
4838 assert_eq!(e.purpose.as_deref(), Some("Approve salary change"));
4839 assert_eq!(e.timeout.as_deref(), Some("24h"));
4840 assert!(e.on_error.is_none());
4841 assert!(e.config_override.is_none());
4842 assert_eq!(e.source, "test.policy[0]");
4843 }
4844
4845 #[test]
4846 fn parse_channel_is_optional() {
4847 let e = parse_elicit_str("require_approval(manager-approver, from: user.manager)");
4849 assert_eq!(e.plugin_name, "manager-approver");
4850 assert!(e.channel.is_none());
4851 }
4852
4853 #[test]
4854 fn parse_each_verb_maps_to_its_kind() {
4855 for (verb, want) in [
4856 ("require_approval", ElicitKind::Approval),
4857 ("confirm", ElicitKind::Confirm),
4858 ("require_step_up", ElicitKind::StepUp),
4859 ("require_attestation", ElicitKind::Attestation),
4860 ("request_info", ElicitKind::Info),
4861 ("require_review", ElicitKind::Review),
4862 ] {
4863 let e = parse_elicit_str(&format!("{verb}(inband-asker, from: user.sub)"));
4864 assert_eq!(e.kind, want, "verb `{verb}`");
4865 assert_eq!(e.plugin_name, "inband-asker");
4866 assert_eq!(e.from, "user.sub");
4867 }
4868 }
4869
4870 #[test]
4871 fn parse_confirm_prompt_aliases_purpose() {
4872 let e =
4875 parse_elicit_str("confirm(inband-asker, from: user.sub, prompt: \"Drop the table?\")");
4876 assert_eq!(e.kind, ElicitKind::Confirm);
4877 assert_eq!(e.purpose.as_deref(), Some("Drop the table?"));
4878 }
4879
4880 #[test]
4881 fn parse_unknown_kwarg_goes_to_config_override() {
4882 let e = parse_elicit_str(
4883 "require_approval(slack-approver, from: user.manager, \
4884 details_link: https://approvals.example.com/req)",
4885 );
4886 let cfg = e.config_override.as_ref().unwrap().as_mapping().unwrap();
4887 assert_eq!(
4888 cfg.get(serde_yaml::Value::String("details_link".into()))
4889 .and_then(|v| v.as_str()),
4890 Some("https://approvals.example.com/req"),
4891 );
4892 assert!(cfg.get(serde_yaml::Value::String("from".into())).is_none());
4894 }
4895
4896 #[test]
4897 fn parse_on_error_pulled_out() {
4898 let e = parse_elicit_str(
4899 "require_approval(manager-approver, from: user.manager, on_error: continue)",
4900 );
4901 assert_eq!(e.on_error.as_deref(), Some("continue"));
4902 assert!(e.config_override.is_none());
4903 }
4904
4905 #[test]
4906 fn parse_missing_plugin_name_errors() {
4907 let value = serde_yaml::Value::String("require_approval(from: user.manager)".into());
4908 let err = parse_step(&value, "test.policy[0]").expect_err("missing plugin name");
4909 assert!(format!("{err}").contains("requires `from`"), "got: {err}");
4912 }
4913
4914 #[test]
4915 fn parse_empty_args_errors() {
4916 let value = serde_yaml::Value::String("require_approval()".into());
4917 let err = parse_step(&value, "test.policy[0]").expect_err("empty args");
4918 assert!(format!("{err}").contains("plugin name"), "got: {err}");
4919 }
4920
4921 #[test]
4922 fn parse_plugin_kwarg_rejected() {
4923 let value = serde_yaml::Value::String(
4925 "require_approval(manager-approver, plugin: other, from: user.manager)".into(),
4926 );
4927 let err = parse_step(&value, "test.policy[0]").expect_err("plugin kwarg");
4928 assert!(format!("{err}").contains("first positional"), "got: {err}");
4929 }
4930
4931 #[test]
4932 fn parse_missing_from_errors() {
4933 let value = serde_yaml::Value::String("require_approval(manager-approver)".into());
4934 let err = parse_step(&value, "test.policy[0]").expect_err("missing from");
4935 assert!(format!("{err}").contains("requires `from`"));
4936 }
4937
4938 #[test]
4939 fn parse_require_prefixed_verbs_do_not_collide() {
4940 let e = parse_elicit_str("require_attestation(inband-asker, from: user.sub)");
4943 assert_eq!(e.kind, ElicitKind::Attestation);
4944 }
4945
4946 #[test]
4947 fn compile_route_with_require_approval_in_policy() {
4948 let yaml = r#"
4952routes:
4953 payroll_adjust:
4954 pre_invocation:
4955 - "require(authenticated)"
4956 - when: "args.amount > 10000"
4957 do:
4958 - "require_approval(manager-approver, from: user.manager, channel: \"ciba\", scope: \"args.amount <= 25000\", purpose: \"Approve salary change\", timeout: 24h)"
4959"#;
4960 let cfg = compile_config(yaml).expect("compile");
4961 let route = cfg.routes.get("payroll_adjust").expect("route present");
4962 assert_eq!(route.policy.len(), 2);
4963
4964 let crate::rules::Effect::When { body, .. } = &route.policy[1] else {
4966 panic!("expected When at policy[1], got {:?}", route.policy[1]);
4967 };
4968 let crate::rules::Effect::Elicit(e) = &body[0] else {
4969 panic!("expected Elicit in when-body, got {:?}", body[0]);
4970 };
4971 assert_eq!(e.kind, ElicitKind::Approval);
4972 assert_eq!(e.plugin_name, "manager-approver");
4973 assert_eq!(e.from, "user.manager");
4974 assert_eq!(e.channel.as_deref(), Some("ciba"));
4975 assert_eq!(e.scope.as_deref(), Some("args.amount <= 25000"));
4976 }
4977
4978 #[test]
4981 fn parse_pipeline_rejects_validate_stage_at_compile_time() {
4982 let err = parse_pipeline("str | validate(ssn_format) | mask(4)")
4987 .expect_err("validate(name) should fail to parse");
4988 let msg = format!("{err}");
4989 assert!(
4990 msg.contains("not implemented"),
4991 "diagnostic should explain that validate is unimplemented: {msg}",
4992 );
4993 assert!(
4994 msg.contains("regex") && msg.contains("plugin"),
4995 "diagnostic should suggest regex(...) and plugin(...): {msg}",
4996 );
4997 assert!(
4998 msg.contains("ssn_format"),
4999 "diagnostic should echo the rejected validator name: {msg}",
5000 );
5001 }
5002
5003 #[test]
5004 fn parse_pipeline_does_not_reject_other_stages() {
5005 let p = parse_pipeline("str | len(..100) | regex(\"^[A-Z]+$\") | mask(4)")
5008 .expect("non-validate pipeline parses");
5009 assert_eq!(p.stages.len(), 4);
5010 }
5011}