1use std::collections::HashMap;
25
26use serde::Deserialize;
27use thiserror::Error;
28
29use crate::pipeline::{FieldRule, Pipeline, ScanKind, Stage, TaintScope, TypeCheck};
30use crate::plugin_decl::{PluginDeclaration, PluginOverride, PluginRegistry};
31use crate::rules::{CompareOp, CompiledRoute, Condition, Effect, Expression, Literal, Rule};
32use crate::step::{DelegateStep, PdpCall, PdpDialect, Step};
33
34#[derive(Debug, Error)]
39pub enum ParseError {
40 #[error("YAML parse error: {0}")]
41 Yaml(#[from] serde_yaml::Error),
42
43 #[error("rule '{rule}': {msg}")]
44 Rule { rule: String, msg: String },
45
46 #[error("unsupported step `{kind}` in rule '{rule}' — defer to step 5b")]
47 UnsupportedStep { rule: String, kind: String },
48
49 #[error("predicate '{predicate}': {msg}")]
50 Predicate { predicate: String, msg: String },
51}
52
53#[derive(Debug, Clone, PartialEq)]
58enum Tok {
59 Ident(String), StringLit(String),
61 IntLit(i64),
62 FloatLit(f64),
63 BoolLit(bool),
64 Eq, NotEq, Gt, GtEq, Lt, LtEq, And, Or, Not, LParen,
74 RParen,
75 Comma,
76 Contains, Require, Exists, In, }
81
82struct Lexer<'a> {
83 src: &'a str,
84 bytes: &'a [u8],
85 pos: usize,
86}
87
88impl<'a> Lexer<'a> {
89 fn new(src: &'a str) -> Self {
90 Self {
91 src,
92 bytes: src.as_bytes(),
93 pos: 0,
94 }
95 }
96
97 fn peek(&self) -> Option<u8> {
98 self.bytes.get(self.pos).copied()
99 }
100
101 fn bump(&mut self) -> Option<u8> {
102 let b = self.peek()?;
103 self.pos += 1;
104 Some(b)
105 }
106
107 fn skip_ws(&mut self) {
108 while let Some(b) = self.peek() {
109 if b.is_ascii_whitespace() {
110 self.pos += 1;
111 } else {
112 break;
113 }
114 }
115 }
116
117 fn tokenize_all(&mut self) -> Result<Vec<Tok>, ParseError> {
118 let mut out = Vec::new();
119 loop {
120 self.skip_ws();
121 let Some(b) = self.peek() else {
122 return Ok(out);
123 };
124
125 let tok = match b {
126 b'(' => {
127 self.pos += 1;
128 Tok::LParen
129 },
130 b')' => {
131 self.pos += 1;
132 Tok::RParen
133 },
134 b',' => {
135 self.pos += 1;
136 Tok::Comma
137 },
138 b'&' => {
139 self.pos += 1;
140 Tok::And
141 },
142 b'|' => {
143 self.pos += 1;
144 Tok::Or
145 },
146 b'=' => {
147 self.pos += 1;
148 if self.peek() == Some(b'=') {
149 self.pos += 1;
150 Tok::Eq
151 } else {
152 return Err(self.err("expected `==`, saw `=`"));
153 }
154 },
155 b'!' => {
156 self.pos += 1;
157 if self.peek() == Some(b'=') {
158 self.pos += 1;
159 Tok::NotEq
160 } else {
161 Tok::Not
162 }
163 },
164 b'>' => {
165 self.pos += 1;
166 if self.peek() == Some(b'=') {
167 self.pos += 1;
168 Tok::GtEq
169 } else {
170 Tok::Gt
171 }
172 },
173 b'<' => {
174 self.pos += 1;
175 if self.peek() == Some(b'=') {
176 self.pos += 1;
177 Tok::LtEq
178 } else {
179 Tok::Lt
180 }
181 },
182 b'"' | b'\'' => self.lex_string(b)?,
183 b'-' | b'0'..=b'9' => self.lex_number()?,
184 b if is_ident_start(b) => self.lex_ident_or_keyword(),
185 _ => return Err(self.err(&format!("unexpected char `{}`", b as char))),
186 };
187 out.push(tok);
188 }
189 }
190
191 fn lex_string(&mut self, quote: u8) -> Result<Tok, ParseError> {
192 self.bump(); let start = self.pos;
194 while let Some(b) = self.peek() {
195 if b == quote {
196 break;
197 }
198 self.pos += 1;
199 }
200 if self.peek() != Some(quote) {
201 return Err(self.err("unterminated string literal"));
202 }
203 let s = std::str::from_utf8(&self.bytes[start..self.pos])
204 .map_err(|_| self.err("non-utf8 in string literal"))?
205 .to_string();
206 self.bump(); Ok(Tok::StringLit(s))
208 }
209
210 fn lex_number(&mut self) -> Result<Tok, ParseError> {
211 let start = self.pos;
212 if self.peek() == Some(b'-') {
213 self.pos += 1;
214 }
215 while let Some(b) = self.peek() {
216 if b.is_ascii_digit() {
217 self.pos += 1;
218 } else {
219 break;
220 }
221 }
222 let mut is_float = false;
223 if self.peek() == Some(b'.') {
224 is_float = true;
225 self.pos += 1;
226 while let Some(b) = self.peek() {
227 if b.is_ascii_digit() {
228 self.pos += 1;
229 } else {
230 break;
231 }
232 }
233 }
234 let text = &self.src[start..self.pos];
235 if is_float {
236 text.parse::<f64>()
237 .map(Tok::FloatLit)
238 .map_err(|_| self.err(&format!("bad float `{}`", text)))
239 } else {
240 text.parse::<i64>()
241 .map(Tok::IntLit)
242 .map_err(|_| self.err(&format!("bad int `{}`", text)))
243 }
244 }
245
246 fn lex_ident_or_keyword(&mut self) -> Tok {
247 let start = self.pos;
248 while let Some(b) = self.peek() {
249 if is_ident_cont(b) {
250 self.pos += 1;
251 } else {
252 break;
253 }
254 }
255 let s = &self.src[start..self.pos];
256 match s {
257 "true" => Tok::BoolLit(true),
258 "false" => Tok::BoolLit(false),
259 "contains" => Tok::Contains,
260 "require" => Tok::Require,
261 "exists" => Tok::Exists,
262 "in" => Tok::In,
263 _ => Tok::Ident(s.to_string()),
267 }
268 }
269
270 fn err(&self, msg: &str) -> ParseError {
271 ParseError::Predicate {
272 predicate: self.src.to_string(),
273 msg: format!("at byte {}: {}", self.pos, msg),
274 }
275 }
276}
277
278fn is_ident_start(b: u8) -> bool {
279 b.is_ascii_alphabetic() || b == b'_'
280}
281
282fn is_ident_cont(b: u8) -> bool {
283 b.is_ascii_alphanumeric() || b == b'_' || b == b'.'
284}
285
286struct PredParser<'a> {
291 src: &'a str,
292 toks: Vec<Tok>,
293 pos: usize,
294}
295
296impl<'a> PredParser<'a> {
297 fn parse(src: &'a str) -> Result<Expression, ParseError> {
298 let toks = Lexer::new(src).tokenize_all()?;
299 let mut p = Self { src, toks, pos: 0 };
300 let expr = p.parse_or()?;
301 if p.pos < p.toks.len() {
302 return Err(p.err(&format!(
303 "trailing tokens after expression: {:?}",
304 &p.toks[p.pos..]
305 )));
306 }
307 Ok(expr)
308 }
309
310 fn peek(&self) -> Option<&Tok> {
311 self.toks.get(self.pos)
312 }
313 fn bump(&mut self) -> Option<Tok> {
314 let t = self.toks.get(self.pos).cloned()?;
315 self.pos += 1;
316 Some(t)
317 }
318 fn err(&self, msg: &str) -> ParseError {
319 ParseError::Predicate {
320 predicate: self.src.to_string(),
321 msg: msg.to_string(),
322 }
323 }
324
325 fn parse_or(&mut self) -> Result<Expression, ParseError> {
326 let mut parts = vec![self.parse_and()?];
327 while matches!(self.peek(), Some(Tok::Or)) {
328 self.bump();
329 parts.push(self.parse_and()?);
330 }
331 Ok(if parts.len() == 1 {
332 parts.pop().unwrap()
333 } else {
334 Expression::Or(parts)
335 })
336 }
337
338 fn parse_and(&mut self) -> Result<Expression, ParseError> {
339 let mut parts = vec![self.parse_unary()?];
340 while matches!(self.peek(), Some(Tok::And)) {
341 self.bump();
342 parts.push(self.parse_unary()?);
343 }
344 Ok(if parts.len() == 1 {
345 parts.pop().unwrap()
346 } else {
347 Expression::And(parts)
348 })
349 }
350
351 fn parse_unary(&mut self) -> Result<Expression, ParseError> {
352 if matches!(self.peek(), Some(Tok::Not)) {
353 self.bump();
354 let inner = self.parse_unary()?;
355 return Ok(Expression::Not(Box::new(inner)));
356 }
357 self.parse_atom()
358 }
359
360 fn parse_atom(&mut self) -> Result<Expression, ParseError> {
361 match self.peek() {
362 Some(Tok::LParen) => {
363 self.bump();
364 let inner = self.parse_or()?;
365 match self.bump() {
366 Some(Tok::RParen) => Ok(inner),
367 _ => Err(self.err("expected `)`")),
368 }
369 },
370 Some(Tok::Require) => Err(self.err(
374 "`require(...)` is a rule-level shorthand, not a sub-predicate \
375 — use `&` / `|` / `!` over bare identifiers instead",
376 )),
377 Some(Tok::Exists) => self.parse_exists(),
378 Some(Tok::Ident(_)) => self.parse_identifier_predicate(),
379 other => Err(self.err(&format!("expected atom, got {:?}", other))),
380 }
381 }
382
383 fn parse_exists(&mut self) -> Result<Expression, ParseError> {
386 self.bump(); match self.bump() {
388 Some(Tok::LParen) => {},
389 _ => return Err(self.err("expected `(` after `exists`")),
390 }
391 let key = match self.bump() {
392 Some(Tok::Ident(s)) => s,
393 other => {
394 return Err(self.err(&format!(
395 "exists(...) expects an attribute key, got {:?}",
396 other,
397 )))
398 },
399 };
400 match self.bump() {
401 Some(Tok::RParen) => {},
402 other => {
403 return Err(self.err(&format!(
404 "expected `)` after exists() argument, got {:?}",
405 other,
406 )))
407 },
408 }
409 Ok(Expression::Condition(Condition::Exists { key }))
410 }
411
412 fn parse_identifier_predicate(&mut self) -> Result<Expression, ParseError> {
419 let key = match self.bump() {
420 Some(Tok::Ident(s)) => s,
421 _ => unreachable!("parse_atom dispatched here"),
422 };
423
424 if matches!(self.peek(), Some(Tok::In)) {
426 self.bump();
427 return self.finish_in_set(key, false);
428 }
429 if let Some(Tok::Ident(maybe_not)) = self.peek() {
433 if maybe_not == "not" {
434 let saved_pos = self.pos;
435 self.bump(); if matches!(self.peek(), Some(Tok::In)) {
437 self.bump();
438 return self.finish_in_set(key, true);
439 }
440 self.pos = saved_pos;
443 }
444 }
445
446 let op = match self.peek() {
447 Some(Tok::Eq) => Some(CompareOp::Eq),
448 Some(Tok::NotEq) => Some(CompareOp::NotEq),
449 Some(Tok::Gt) => Some(CompareOp::Gt),
450 Some(Tok::GtEq) => Some(CompareOp::GtEq),
451 Some(Tok::Lt) => Some(CompareOp::Lt),
452 Some(Tok::LtEq) => Some(CompareOp::LtEq),
453 Some(Tok::Contains) => Some(CompareOp::Contains),
454 _ => None,
455 };
456
457 let Some(op) = op else {
458 return Ok(Expression::Condition(Condition::IsTrue { key }));
460 };
461 self.bump();
462
463 let value = match self.bump() {
464 Some(Tok::StringLit(s)) => Literal::String(s),
465 Some(Tok::IntLit(i)) => Literal::Int(i),
466 Some(Tok::FloatLit(f)) => Literal::Float(f),
467 Some(Tok::BoolLit(b)) => Literal::Bool(b),
468 Some(Tok::Ident(_)) => {
469 return Err(self.err(
470 "RHS-as-identifier on comparison operators not supported — \
471 for set membership use `value_key in set_key`",
472 ));
473 },
474 other => return Err(self.err(&format!("expected literal RHS, got {:?}", other))),
475 };
476
477 Ok(Expression::Condition(Condition::Comparison {
478 key,
479 op,
480 value,
481 }))
482 }
483
484 fn finish_in_set(&mut self, value_key: String, negate: bool) -> Result<Expression, ParseError> {
485 let set_key = match self.bump() {
486 Some(Tok::Ident(s)) => s,
487 other => {
488 return Err(self.err(&format!(
489 "expected set-attribute identifier after `{}in`, got {:?}",
490 if negate { "not " } else { "" },
491 other,
492 )))
493 },
494 };
495 Ok(Expression::Condition(Condition::InSet {
496 value_key,
497 set_key,
498 negate,
499 }))
500 }
501}
502
503pub fn parse_predicate(src: &str) -> Result<Expression, ParseError> {
505 PredParser::parse(src.trim())
506}
507
508pub fn parse_rule(line: &str, source: &str) -> Result<Rule, ParseError> {
527 let trimmed = line.trim();
528
529 if is_require_call(trimmed) {
533 let condition = parse_require_rule(trimmed)?;
534 return Ok(Rule::single(
535 condition,
536 Effect::Deny {
537 reason: None,
538 code: None,
539 },
540 source,
541 ));
542 }
543
544 if let Some(kind) = detect_step_kind(trimmed) {
547 return Err(ParseError::UnsupportedStep {
548 rule: trimmed.to_string(),
549 kind: format!("{} (use parse_step for step kinds)", kind),
550 });
551 }
552
553 let (predicate_str, effects) = match split_predicate_action(trimmed) {
554 Some((p, a)) => (p, parse_action(a, trimmed)?),
555 None => {
556 if let Some(effects) = try_bare_action(trimmed) {
558 return Ok(Rule {
559 condition: Expression::Always,
560 effects,
561 source: source.to_string(),
562 });
563 }
564 if let Some(deny) = try_parse_deny_call(trimmed, trimmed)? {
571 return Ok(Rule {
572 condition: Expression::Always,
573 effects: vec![deny],
574 source: source.to_string(),
575 });
576 }
577 (
579 trimmed,
580 vec![Effect::Deny {
581 reason: None,
582 code: None,
583 }],
584 )
585 },
586 };
587
588 let condition = parse_predicate(predicate_str).map_err(|e| ParseError::Rule {
589 rule: trimmed.to_string(),
590 msg: format!("{}", e),
591 })?;
592
593 Ok(Rule {
594 condition,
595 effects,
596 source: source.to_string(),
597 })
598}
599
600fn is_require_call(s: &str) -> bool {
601 s.trim_start().starts_with("require(")
602}
603
604fn parse_require_rule(line: &str) -> Result<Expression, ParseError> {
613 let toks = Lexer::new(line).tokenize_all()?;
614 let mut iter = toks.into_iter().peekable();
615
616 let bad = |msg: &str| ParseError::Rule {
617 rule: line.to_string(),
618 msg: msg.to_string(),
619 };
620
621 match iter.next() {
622 Some(Tok::Require) => {},
623 _ => return Err(bad("expected `require`")),
624 }
625 match iter.next() {
626 Some(Tok::LParen) => {},
627 _ => return Err(bad("expected `(` after `require`")),
628 }
629
630 let mut keys = Vec::new();
631 let mut sep: Option<Tok> = None;
632
633 match iter.next() {
634 Some(Tok::Ident(s)) => keys.push(s),
635 _ => return Err(bad("expected identifier inside `require(...)`")),
636 }
637
638 loop {
639 match iter.next() {
640 Some(Tok::RParen) => break,
641 Some(t @ Tok::Comma) | Some(t @ Tok::Or) => {
642 match &sep {
643 None => sep = Some(t),
644 Some(prev) if std::mem::discriminant(prev) == std::mem::discriminant(&t) => {},
645 _ => {
646 return Err(bad(
647 "require(...) cannot mix `,` (AND) and `|` (OR) — use one or the other",
648 ))
649 },
650 }
651 match iter.next() {
652 Some(Tok::Ident(s)) => keys.push(s),
653 _ => return Err(bad("expected identifier after `,` or `|` in require(...)")),
654 }
655 },
656 Some(other) => {
657 return Err(bad(&format!(
658 "expected `,`, `|`, or `)` in require(...), got {:?}",
659 other,
660 )))
661 },
662 None => return Err(bad("unexpected end of require(...) — missing `)`")),
663 }
664 }
665
666 if iter.peek().is_some() {
667 return Err(bad(
668 "trailing tokens after `require(...)` — require is a complete rule",
669 ));
670 }
671
672 let falses: Vec<Expression> = keys
673 .into_iter()
674 .map(|k| Expression::Condition(Condition::IsFalse { key: k }))
675 .collect();
676 if falses.len() == 1 {
677 return Ok(falses.into_iter().next().unwrap());
678 }
679 Ok(match sep {
680 Some(Tok::Or) => Expression::And(falses), _ => Expression::Or(falses), })
683}
684
685fn detect_step_kind(s: &str) -> Option<&'static str> {
687 let s = s.trim_start();
688 for prefix in [
689 "taint(",
690 "plugin(",
691 "run(",
692 "cedar:",
693 "opa(",
694 "authzen(",
695 "nemo(",
696 "cel:",
697 "sequential:",
698 "parallel:",
699 ] {
700 if s.starts_with(prefix) {
701 return Some(prefix.trim_end_matches('(').trim_end_matches(':'));
702 }
703 }
704 None
705}
706
707fn split_predicate_action(s: &str) -> Option<(&str, &str)> {
712 let bytes = s.as_bytes();
713 let mut depth: i32 = 0;
714 let mut in_quote: Option<u8> = None;
715 let mut last_colon: Option<usize> = None;
716 for (i, &b) in bytes.iter().enumerate() {
717 match (in_quote, b) {
718 (Some(q), c) if c == q => in_quote = None,
719 (Some(_), _) => {},
720 (None, b'"') | (None, b'\'') => in_quote = Some(b),
721 (None, b'(') => depth += 1,
722 (None, b')') => depth -= 1,
723 (None, b':') if depth == 0 => last_colon = Some(i),
724 _ => {},
725 }
726 }
727 last_colon.map(|i| (s[..i].trim(), s[i + 1..].trim()))
728}
729
730fn parse_action(s: &str, rule: &str) -> Result<Vec<Effect>, ParseError> {
743 if let Some(effect) = try_bare_action(s) {
744 return Ok(effect);
745 }
746 if let Some(deny) = try_parse_deny_call(s.trim(), rule)? {
747 return Ok(vec![deny]);
748 }
749 Err(ParseError::Rule {
750 rule: rule.to_string(),
751 msg: format!(
752 "unsupported action `{}` — recognized: `deny`, `deny('reason')`, `deny('reason', 'code')`, `allow`",
753 s.trim()
754 ),
755 })
756}
757
758fn try_bare_action(s: &str) -> Option<Vec<Effect>> {
759 match s.trim() {
760 "deny" => Some(vec![Effect::Deny {
761 reason: None,
762 code: None,
763 }]),
764 "allow" => Some(vec![Effect::Allow]),
765 _ => None,
766 }
767}
768
769fn try_parse_deny_call(s: &str, rule: &str) -> Result<Option<Effect>, ParseError> {
773 if !s.starts_with("deny(") {
774 return Ok(None);
775 }
776 let inside = extract_call_args(s, "deny").ok_or_else(|| ParseError::Rule {
777 rule: rule.to_string(),
778 msg: "malformed `deny(...)`".into(),
779 })?;
780 let parts = split_top_level_commas(&inside).map_err(|e| ParseError::Rule {
783 rule: rule.to_string(),
784 msg: format!("deny(...): {}", e),
785 })?;
786 let mut iter = parts.into_iter();
787 let reason = match iter.next() {
788 Some(p) => Some(strip_string_literal(p.trim(), rule)?),
789 None => None,
790 };
791 let code = match iter.next() {
792 Some(p) => Some(strip_string_literal(p.trim(), rule)?),
793 None => None,
794 };
795 if iter.next().is_some() {
796 return Err(ParseError::Rule {
797 rule: rule.to_string(),
798 msg: "deny(...) takes at most two args: deny('reason', 'code')".into(),
799 });
800 }
801 Ok(Some(Effect::Deny { reason, code }))
802}
803
804fn strip_string_literal(s: &str, rule: &str) -> Result<String, ParseError> {
808 let s = s.trim();
809 if (s.starts_with('\'') && s.ends_with('\'') && s.len() >= 2)
810 || (s.starts_with('"') && s.ends_with('"') && s.len() >= 2)
811 {
812 Ok(s[1..s.len() - 1].to_string())
813 } else {
814 Err(ParseError::Rule {
815 rule: rule.to_string(),
816 msg: format!("expected a quoted string, got `{}`", s),
817 })
818 }
819}
820
821pub fn parse_step(value: &serde_yaml::Value, source: &str) -> Result<Step, ParseError> {
837 match value {
838 serde_yaml::Value::String(s) => parse_step_string(s, source),
839 serde_yaml::Value::Mapping(m) => parse_step_map(m, source),
840 other => Err(ParseError::Rule {
841 rule: format!("{:?}", other),
842 msg: "step must be a string or a single-key map".into(),
843 }),
844 }
845}
846
847fn parse_step_string(line: &str, source: &str) -> Result<Step, ParseError> {
848 let trimmed = line.trim();
849
850 if trimmed.starts_with("taint(") {
853 let inside = extract_call_args(trimmed, "taint").ok_or_else(|| ParseError::Rule {
854 rule: trimmed.to_string(),
855 msg: "malformed `taint(...)`".into(),
856 })?;
857 let taint_stage = parse_taint(&inside, trimmed)?;
858 if let Stage::Taint { label, scopes } = taint_stage {
860 return Ok(Step::Taint { label, scopes });
861 }
862 unreachable!("parse_taint always returns Stage::Taint");
863 }
864
865 let plugin_verb = if trimmed.starts_with("plugin(") {
868 Some("plugin")
869 } else if trimmed.starts_with("run(") {
870 Some("run")
871 } else {
872 None
873 };
874 if let Some(verb) = plugin_verb {
875 let inside = extract_call_args(trimmed, verb).ok_or_else(|| ParseError::Rule {
876 rule: trimmed.to_string(),
877 msg: format!("malformed `{verb}(...)`"),
878 })?;
879 let name = inside.trim();
880 if name.is_empty() {
881 return Err(ParseError::Rule {
882 rule: trimmed.to_string(),
883 msg: format!("`{verb}(...)`: plugin name must not be empty"),
884 });
885 }
886 return Ok(Step::Plugin {
887 name: name.to_string(),
888 });
889 }
890
891 if trimmed.starts_with("delegate(") {
898 let inside = extract_call_args(trimmed, "delegate").ok_or_else(|| ParseError::Rule {
899 rule: trimmed.to_string(),
900 msg: "malformed `delegate(...)`".into(),
901 })?;
902 let parsed = parse_delegate_call_args(&inside, source)?;
903 return Ok(Step::Delegate(DelegateStep {
904 plugin_name: parsed.plugin_name,
905 config_override: parsed.config_override,
906 on_error: parsed.on_error,
907 source: source.to_string(),
908 }));
909 }
910
911 let rule = parse_rule(trimmed, source)?;
913 Ok(Step::Rule(rule))
914}
915
916struct ParsedDelegateCall {
920 plugin_name: String,
921 config_override: Option<serde_yaml::Value>,
922 on_error: Option<String>,
923}
924
925fn parse_delegate_call_args(inside: &str, source: &str) -> Result<ParsedDelegateCall, ParseError> {
943 let parts = split_top_level_commas(inside).map_err(|msg| ParseError::Rule {
944 rule: format!("delegate({inside})"),
945 msg: format!("{source}: {msg}"),
946 })?;
947 let mut parts_iter = parts.into_iter();
948
949 let plugin_name = parts_iter
950 .next()
951 .map(|s| s.trim().to_string())
952 .filter(|s| !s.is_empty())
953 .ok_or_else(|| ParseError::Rule {
954 rule: format!("delegate({inside})"),
955 msg: format!(
956 "{source}: `delegate(...)` requires a plugin name as the first \
957 positional argument"
958 ),
959 })?;
960 let plugin_name = strip_wrapping_quotes(&plugin_name).to_string();
962 if plugin_name.is_empty() {
963 return Err(ParseError::Rule {
964 rule: format!("delegate({inside})"),
965 msg: format!("{source}: `delegate(...)` plugin name cannot be empty"),
966 });
967 }
968
969 let mut on_error: Option<String> = None;
970 let mut config_map = serde_yaml::Mapping::new();
971
972 for raw_kwarg in parts_iter {
973 let kwarg = raw_kwarg.trim();
974 if kwarg.is_empty() {
975 continue;
976 }
977 let (key, value_str) = kwarg.split_once(':').ok_or_else(|| ParseError::Rule {
978 rule: kwarg.to_string(),
979 msg: format!(
980 "{source}: `delegate(...)` kwarg `{kwarg}` must be `key: value` \
981 (use the map form for richer config)"
982 ),
983 })?;
984 let key = key.trim();
985 let value_str = value_str.trim();
986 if key.is_empty() {
987 return Err(ParseError::Rule {
988 rule: kwarg.to_string(),
989 msg: format!("{source}: `delegate(...)` kwarg has empty key"),
990 });
991 }
992 if key == "on_error" {
993 let val = parse_delegate_value(value_str).map_err(|msg| ParseError::Rule {
994 rule: kwarg.to_string(),
995 msg: format!("{source}: on_error: {msg}"),
996 })?;
997 on_error = Some(
998 val.as_str()
999 .ok_or_else(|| ParseError::Rule {
1000 rule: kwarg.to_string(),
1001 msg: format!("{source}: `on_error` must be a string"),
1002 })?
1003 .to_string(),
1004 );
1005 continue;
1006 }
1007 if key == "plugin" {
1010 return Err(ParseError::Rule {
1011 rule: kwarg.to_string(),
1012 msg: format!(
1013 "{source}: `plugin` is set as the first positional argument \
1014 of `delegate(...)`; don't pass it as a kwarg too"
1015 ),
1016 });
1017 }
1018 let value = parse_delegate_value(value_str).map_err(|msg| ParseError::Rule {
1019 rule: kwarg.to_string(),
1020 msg: format!("{source}: `{key}`: {msg}"),
1021 })?;
1022 config_map.insert(serde_yaml::Value::String(key.to_string()), value);
1023 }
1024
1025 let config_override = if config_map.is_empty() {
1026 None
1027 } else {
1028 Some(serde_yaml::Value::Mapping(config_map))
1029 };
1030
1031 Ok(ParsedDelegateCall {
1032 plugin_name,
1033 config_override,
1034 on_error,
1035 })
1036}
1037
1038fn split_top_level_commas(input: &str) -> Result<Vec<String>, String> {
1046 let mut parts = Vec::new();
1047 let mut current = String::new();
1048 let mut bracket_depth: usize = 0;
1049 let mut quote: Option<char> = None;
1050 let mut escape = false;
1051
1052 for ch in input.chars() {
1053 if escape {
1054 current.push(ch);
1055 escape = false;
1056 continue;
1057 }
1058 if let Some(q) = quote {
1059 current.push(ch);
1060 if ch == '\\' {
1061 escape = true;
1062 } else if ch == q {
1063 quote = None;
1064 }
1065 continue;
1066 }
1067 match ch {
1068 '"' | '\'' => {
1069 quote = Some(ch);
1070 current.push(ch);
1071 },
1072 '[' | '(' | '{' => {
1073 bracket_depth += 1;
1074 current.push(ch);
1075 },
1076 ']' | ')' | '}' => {
1077 bracket_depth = bracket_depth
1078 .checked_sub(1)
1079 .ok_or_else(|| format!("unmatched `{ch}` in delegate(...) args"))?;
1080 current.push(ch);
1081 },
1082 ',' if bracket_depth == 0 => {
1083 parts.push(std::mem::take(&mut current));
1084 },
1085 _ => current.push(ch),
1086 }
1087 }
1088 if quote.is_some() {
1089 return Err("unterminated quoted string in delegate(...) args".to_string());
1090 }
1091 if bracket_depth != 0 {
1092 return Err("unbalanced brackets in delegate(...) args".to_string());
1093 }
1094 parts.push(current);
1095 Ok(parts)
1096}
1097
1098fn parse_delegate_value(s: &str) -> Result<serde_yaml::Value, String> {
1102 let trimmed = s.trim();
1103 if trimmed.is_empty() {
1104 return Err("empty value".to_string());
1105 }
1106 if let Some(stripped) = trimmed.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
1108 let items = split_top_level_commas(stripped)?;
1109 let mut out = Vec::with_capacity(items.len());
1110 for item in items {
1111 let item = item.trim();
1112 if item.is_empty() {
1113 continue;
1114 }
1115 out.push(parse_delegate_value(item)?);
1116 }
1117 return Ok(serde_yaml::Value::Sequence(out));
1118 }
1119 if (trimmed.starts_with('"') && trimmed.ends_with('"') && trimmed.len() >= 2)
1121 || (trimmed.starts_with('\'') && trimmed.ends_with('\'') && trimmed.len() >= 2)
1122 {
1123 return Ok(serde_yaml::Value::String(
1124 trimmed[1..trimmed.len() - 1].to_string(),
1125 ));
1126 }
1127 if trimmed == "true" {
1129 return Ok(serde_yaml::Value::Bool(true));
1130 }
1131 if trimmed == "false" {
1132 return Ok(serde_yaml::Value::Bool(false));
1133 }
1134 if let Ok(n) = trimmed.parse::<i64>() {
1136 return Ok(serde_yaml::Value::Number(serde_yaml::Number::from(n)));
1137 }
1138 if let Ok(f) = trimmed.parse::<f64>() {
1139 return Ok(serde_yaml::Value::Number(serde_yaml::Number::from(f)));
1140 }
1141 Ok(serde_yaml::Value::String(trimmed.to_string()))
1144}
1145
1146fn strip_wrapping_quotes(s: &str) -> &str {
1150 let bytes = s.as_bytes();
1151 if bytes.len() >= 2 {
1152 let first = bytes[0];
1153 let last = bytes[bytes.len() - 1];
1154 if (first == b'"' && last == b'"') || (first == b'\'' && last == b'\'') {
1155 return &s[1..s.len() - 1];
1156 }
1157 }
1158 s
1159}
1160
1161fn parse_step_map(m: &serde_yaml::Mapping, source: &str) -> Result<Step, ParseError> {
1162 if has_key(m, "when") && has_key(m, "do") {
1167 return parse_when_do_rule(m, source);
1168 }
1169
1170 if m.len() != 1 {
1171 return Err(ParseError::Rule {
1172 rule: format!("{:?}", m),
1173 msg: "step map must have exactly one key (PDP call signature, \
1174 `when:`/`do:`, or a `predicate: [effects...]` shorthand)"
1175 .into(),
1176 });
1177 }
1178 let (key_val, body_val) = m.iter().next().unwrap();
1179 let key = key_val.as_str().ok_or_else(|| ParseError::Rule {
1180 rule: format!("{:?}", key_val),
1181 msg: "PDP step key must be a string".into(),
1182 })?;
1183
1184 if let serde_yaml::Value::Sequence(items) = body_val {
1192 let trimmed = key.trim();
1199 if trimmed != "delegate"
1200 && trimmed != "sequential"
1201 && trimmed != "parallel"
1202 && !is_known_pdp_dialect(trimmed)
1203 {
1204 return parse_shorthand_multi_effect(trimmed, items, source);
1205 }
1206 }
1207
1208 if key.trim() == "delegate" {
1211 return parse_delegate_step(body_val, source);
1212 }
1213
1214 match key.trim() {
1218 "sequential" => {
1219 let effect = parse_sequential_effect(body_val, source)?;
1220 return Ok(Step::Rule(Rule {
1221 condition: Expression::Always,
1222 effects: vec![effect],
1223 source: source.to_string(),
1224 }));
1225 },
1226 "parallel" => {
1227 let effect = parse_parallel_effect(body_val, source)?;
1228 return Ok(Step::Rule(Rule {
1229 condition: Expression::Always,
1230 effects: vec![effect],
1231 source: source.to_string(),
1232 }));
1233 },
1234 _ => {},
1235 }
1236
1237 let (dialect_str, paren_args) = if let Some(open) = key.find('(') {
1239 let close = key.rfind(')').ok_or_else(|| ParseError::Rule {
1240 rule: key.to_string(),
1241 msg: "missing `)` in PDP call signature".into(),
1242 })?;
1243 let inside = key[open + 1..close].trim().to_string();
1244 (key[..open].trim(), Some(inside))
1245 } else {
1246 (key.trim(), None)
1247 };
1248
1249 let dialect = PdpDialect::from_key(dialect_str);
1250
1251 let body = body_val.as_mapping().ok_or_else(|| ParseError::Rule {
1255 rule: format!("{:?}", body_val),
1256 msg: format!(
1257 "`{}:` body must be a map (with on_deny / on_allow / args)",
1258 key
1259 ),
1260 })?;
1261
1262 let (args, on_deny, on_allow) = extract_pdp_body(body, paren_args.as_deref(), source)?;
1263
1264 Ok(Step::Pdp {
1265 call: PdpCall { dialect, args },
1266 on_deny,
1267 on_allow,
1268 })
1269}
1270
1271fn has_key(m: &serde_yaml::Mapping, key: &str) -> bool {
1290 m.contains_key(serde_yaml::Value::String(key.to_string()))
1291}
1292
1293fn is_known_pdp_dialect(key: &str) -> bool {
1297 let base = key.find('(').map(|i| &key[..i]).unwrap_or(key);
1298 matches!(base.trim(), "cedar" | "opa" | "authzen" | "nemo" | "cel")
1299}
1300
1301fn parse_when_do_rule(m: &serde_yaml::Mapping, source: &str) -> Result<Step, ParseError> {
1307 for (k, _) in m.iter() {
1312 let key = k.as_str().unwrap_or("");
1313 if !matches!(key, "when" | "do" | "id") {
1314 return Err(ParseError::Rule {
1315 rule: format!("{:?}", m),
1316 msg: format!(
1317 "unexpected key `{}` in when/do rule (allowed: `when`, `do`, `id`)",
1318 key
1319 ),
1320 });
1321 }
1322 }
1323
1324 let when_val = m
1325 .get(serde_yaml::Value::String("when".into()))
1326 .expect("has_key verified above");
1327 let predicate = when_val.as_str().ok_or_else(|| ParseError::Rule {
1328 rule: format!("{:?}", when_val),
1329 msg: "`when:` must be a predicate string".into(),
1330 })?;
1331 let condition = parse_predicate(predicate).map_err(|e| ParseError::Rule {
1332 rule: format!("when: {}", predicate),
1333 msg: format!("{}", e),
1334 })?;
1335
1336 let do_val = m
1337 .get(serde_yaml::Value::String("do".into()))
1338 .expect("has_key verified above");
1339 let effects = parse_do_body(do_val, source)?;
1340 if effects.is_empty() {
1341 return Err(ParseError::Rule {
1342 rule: format!("{:?}", m),
1343 msg: "`do:` produced no effects".into(),
1344 });
1345 }
1346
1347 Ok(Step::Rule(Rule {
1348 condition,
1349 effects,
1350 source: source.to_string(),
1351 }))
1352}
1353
1354fn parse_shorthand_multi_effect(
1358 predicate: &str,
1359 effect_list: &[serde_yaml::Value],
1360 source: &str,
1361) -> Result<Step, ParseError> {
1362 let condition = parse_predicate(predicate).map_err(|e| ParseError::Rule {
1363 rule: predicate.to_string(),
1364 msg: format!("{}", e),
1365 })?;
1366
1367 let mut effects = Vec::with_capacity(effect_list.len());
1368 for item in effect_list {
1369 effects.push(parse_effect_value(item, source)?);
1370 }
1371 if effects.is_empty() {
1372 return Err(ParseError::Rule {
1373 rule: predicate.to_string(),
1374 msg: "shorthand multi-effect map produced no effects".into(),
1375 });
1376 }
1377 Ok(Step::Rule(Rule {
1378 condition,
1379 effects,
1380 source: source.to_string(),
1381 }))
1382}
1383
1384fn parse_do_body(val: &serde_yaml::Value, source: &str) -> Result<Vec<Effect>, ParseError> {
1388 match val {
1389 serde_yaml::Value::String(s) => Ok(vec![parse_effect_string(s, source)?]),
1390 serde_yaml::Value::Sequence(items) => items
1391 .iter()
1392 .map(|item| parse_effect_value(item, source))
1393 .collect(),
1394 serde_yaml::Value::Mapping(_) => {
1395 Ok(vec![parse_effect_value(val, source)?])
1398 },
1399 other => Err(ParseError::Rule {
1400 rule: format!("{:?}", other),
1401 msg: "`do:` value must be a string, a list of effects, or an effect map".into(),
1402 }),
1403 }
1404}
1405
1406fn parse_effect_value(val: &serde_yaml::Value, source: &str) -> Result<Effect, ParseError> {
1410 match val {
1411 serde_yaml::Value::String(s) => parse_effect_string(s, source),
1412 serde_yaml::Value::Mapping(m) => {
1413 if m.len() == 1 {
1417 let (k, v) = m.iter().next().unwrap();
1418 if let Some(key_str) = k.as_str() {
1419 match key_str.trim() {
1420 "sequential" => return parse_sequential_effect(v, source),
1421 "parallel" => return parse_parallel_effect(v, source),
1422 _ => {},
1423 }
1424 }
1425 }
1426 let step = parse_step(val, source)?;
1429 step_to_effect(step, source)
1430 },
1431 other => Err(ParseError::Rule {
1432 rule: format!("{:?}", other),
1433 msg: "effect entry must be a string or a map".into(),
1434 }),
1435 }
1436}
1437
1438fn parse_sequential_effect(body: &serde_yaml::Value, source: &str) -> Result<Effect, ParseError> {
1441 let items = body.as_sequence().ok_or_else(|| ParseError::Rule {
1442 rule: format!("{:?}", body),
1443 msg: "`sequential:` body must be a list of effects".into(),
1444 })?;
1445 if items.is_empty() {
1446 return Err(ParseError::Rule {
1447 rule: format!("{:?}", body),
1448 msg: "`sequential:` body is empty".into(),
1449 });
1450 }
1451 let mut effects = Vec::with_capacity(items.len());
1452 for item in items {
1453 effects.push(parse_effect_value(item, source)?);
1454 }
1455 Ok(Effect::Sequential(effects))
1456}
1457
1458fn parse_parallel_effect(body: &serde_yaml::Value, source: &str) -> Result<Effect, ParseError> {
1462 let items = body.as_sequence().ok_or_else(|| ParseError::Rule {
1463 rule: format!("{:?}", body),
1464 msg: "`parallel:` body must be a list of effects".into(),
1465 })?;
1466 if items.is_empty() {
1467 return Err(ParseError::Rule {
1468 rule: format!("{:?}", body),
1469 msg: "`parallel:` body is empty".into(),
1470 });
1471 }
1472 let mut effects = Vec::with_capacity(items.len());
1473 for item in items {
1474 effects.push(parse_effect_value(item, source)?);
1475 }
1476 let parallel = Effect::Parallel(effects);
1477 parallel
1478 .validate_parallel_purity()
1479 .map_err(|msg| ParseError::Rule {
1480 rule: source.to_string(),
1481 msg,
1482 })?;
1483 Ok(parallel)
1484}
1485
1486fn parse_effect_string(s: &str, source: &str) -> Result<Effect, ParseError> {
1491 let trimmed = s.trim();
1496 if let Some(mut effects) = try_bare_action(trimmed) {
1497 if effects.len() == 1 {
1498 return Ok(effects.pop().unwrap());
1499 }
1500 }
1501 if let Some(effect) = try_parse_deny_call(trimmed, s)? {
1502 return Ok(effect);
1503 }
1504 if let Some(field_op) = try_parse_field_op(trimmed, s)? {
1509 return Ok(field_op);
1510 }
1511 let step = parse_step_string(s, source)?;
1514 step_to_effect(step, source)
1515}
1516
1517fn try_parse_field_op(s: &str, rule: &str) -> Result<Option<Effect>, ParseError> {
1521 let Some(pipe_idx) = find_top_level_pipe(s) else {
1522 return Ok(None);
1523 };
1524 let path = s[..pipe_idx].trim();
1525 let chain = s[pipe_idx + 1..].trim();
1526 if path.is_empty() || chain.is_empty() {
1527 return Ok(None);
1528 }
1529 if !is_valid_field_path(path) {
1534 return Ok(None);
1535 }
1536 let pipeline = parse_pipeline(chain).map_err(|e| ParseError::Rule {
1537 rule: rule.to_string(),
1538 msg: format!("field op `{}`: {}", path, e),
1539 })?;
1540 if pipeline.stages.is_empty() {
1541 return Err(ParseError::Rule {
1542 rule: rule.to_string(),
1543 msg: format!("field op `{}` has no stages", path),
1544 });
1545 }
1546 Ok(Some(Effect::FieldOp {
1547 path: path.to_string(),
1548 stages: pipeline.stages,
1549 }))
1550}
1551
1552fn find_top_level_pipe(s: &str) -> Option<usize> {
1556 let bytes = s.as_bytes();
1557 let mut depth: i32 = 0;
1558 let mut quote: Option<u8> = None;
1559 let mut i = 0;
1560 while i < bytes.len() {
1561 let b = bytes[i];
1562 if let Some(q) = quote {
1563 if b == b'\\' {
1564 i += 2;
1565 continue;
1566 }
1567 if b == q {
1568 quote = None;
1569 }
1570 i += 1;
1571 continue;
1572 }
1573 match b {
1574 b'\'' | b'"' => quote = Some(b),
1575 b'(' | b'[' => depth += 1,
1576 b')' | b']' => depth -= 1,
1577 b'|' if depth == 0 => {
1578 if bytes.get(i + 1) == Some(&b'|') {
1581 i += 2;
1582 continue;
1583 }
1584 return Some(i);
1585 },
1586 _ => {},
1587 }
1588 i += 1;
1589 }
1590 None
1591}
1592
1593fn is_valid_field_path(s: &str) -> bool {
1597 let Some(rest) = s
1598 .strip_prefix("args.")
1599 .or_else(|| s.strip_prefix("result."))
1600 else {
1601 return false;
1602 };
1603 !rest.is_empty()
1604 && rest
1605 .split('.')
1606 .all(|seg| !seg.is_empty() && seg.chars().all(|c| c.is_alphanumeric() || c == '_'))
1607}
1608
1609pub(crate) fn step_to_top_level_effect(step: Step) -> Result<Effect, ParseError> {
1620 match step {
1621 Step::Rule(rule) => Ok(Effect::When {
1622 condition: rule.condition,
1623 body: rule.effects,
1624 source: rule.source,
1625 }),
1626 Step::Pdp {
1627 call,
1628 on_allow,
1629 on_deny,
1630 } => {
1631 let on_allow = on_allow
1632 .into_iter()
1633 .map(step_to_top_level_effect)
1634 .collect::<Result<Vec<_>, _>>()?;
1635 let on_deny = on_deny
1636 .into_iter()
1637 .map(step_to_top_level_effect)
1638 .collect::<Result<Vec<_>, _>>()?;
1639 Ok(Effect::Pdp {
1640 call,
1641 on_allow,
1642 on_deny,
1643 })
1644 },
1645 Step::Plugin { name } => Ok(Effect::Plugin { name }),
1646 Step::Delegate(d) => Ok(Effect::Delegate(d)),
1647 Step::Taint { label, scopes } => Ok(Effect::Taint { label, scopes }),
1648 }
1649}
1650
1651fn step_to_effect(step: Step, source: &str) -> Result<Effect, ParseError> {
1652 match step {
1653 Step::Plugin { name } => Ok(Effect::Plugin { name }),
1654 Step::Delegate(d) => Ok(Effect::Delegate(d)),
1655 Step::Taint { label, scopes } => Ok(Effect::Taint { label, scopes }),
1656 Step::Rule(rule) => {
1657 if !matches!(rule.condition, Expression::Always) {
1660 return Err(ParseError::Rule {
1661 rule: source.to_string(),
1662 msg: "conditional rules nested inside `do:` are not supported in E1 \
1663 (use a sibling `when:`/`do:` rule instead)"
1664 .into(),
1665 });
1666 }
1667 if rule.effects.len() != 1 {
1668 return Err(ParseError::Rule {
1669 rule: source.to_string(),
1670 msg: format!(
1671 "unconditional rule inside `do:` must produce exactly one \
1672 effect, got {}",
1673 rule.effects.len()
1674 ),
1675 });
1676 }
1677 Ok(rule.effects.into_iter().next().unwrap())
1678 },
1679 Step::Pdp { .. } => Err(ParseError::Rule {
1680 rule: source.to_string(),
1681 msg: "PDP calls inside `do:` are not supported in E1 (use a sibling \
1682 step instead)"
1683 .into(),
1684 }),
1685 }
1686}
1687
1688fn parse_delegate_step(body_val: &serde_yaml::Value, source: &str) -> Result<Step, ParseError> {
1693 let body = body_val.as_mapping().ok_or_else(|| ParseError::Rule {
1694 rule: source.to_string(),
1695 msg: "`delegate:` body must be a map with `plugin:` and optional \
1696 `config:` / `on_error:`"
1697 .to_string(),
1698 })?;
1699
1700 let plugin = body
1701 .get(serde_yaml::Value::String("plugin".to_string()))
1702 .ok_or_else(|| ParseError::Rule {
1703 rule: source.to_string(),
1704 msg: "`delegate:` requires `plugin: <name>` referencing a \
1705 top-level plugin registered under `token.delegate`"
1706 .to_string(),
1707 })?;
1708 let plugin_name = plugin
1709 .as_str()
1710 .ok_or_else(|| ParseError::Rule {
1711 rule: source.to_string(),
1712 msg: "`delegate.plugin` must be a string".to_string(),
1713 })?
1714 .to_string();
1715 if plugin_name.is_empty() {
1716 return Err(ParseError::Rule {
1717 rule: source.to_string(),
1718 msg: "`delegate.plugin` cannot be empty".to_string(),
1719 });
1720 }
1721
1722 let config_override = body
1723 .get(serde_yaml::Value::String("config".to_string()))
1724 .cloned();
1725
1726 let on_error = match body.get(serde_yaml::Value::String("on_error".to_string())) {
1727 Some(v) => Some(
1728 v.as_str()
1729 .ok_or_else(|| ParseError::Rule {
1730 rule: source.to_string(),
1731 msg: "`delegate.on_error` must be a string (e.g. `deny`, \
1732 `continue`)"
1733 .to_string(),
1734 })?
1735 .to_string(),
1736 ),
1737 None => None,
1738 };
1739
1740 Ok(Step::Delegate(DelegateStep {
1741 plugin_name,
1742 config_override,
1743 on_error,
1744 source: source.to_string(),
1745 }))
1746}
1747
1748fn extract_pdp_body(
1755 body: &serde_yaml::Mapping,
1756 paren_args: Option<&str>,
1757 source: &str,
1758) -> Result<(serde_yaml::Value, Vec<Step>, Vec<Step>), ParseError> {
1759 let mut on_deny = Vec::new();
1760 let mut on_allow = Vec::new();
1761 let mut args_map = serde_yaml::Mapping::new();
1762
1763 for (k, v) in body {
1764 match k.as_str() {
1765 Some("on_deny") => {
1766 on_deny = parse_reaction_list(v, source, "on_deny")?;
1767 },
1768 Some("on_allow") => {
1769 on_allow = parse_reaction_list(v, source, "on_allow")?;
1770 },
1771 _ => {
1772 args_map.insert(k.clone(), v.clone());
1774 },
1775 }
1776 }
1777
1778 let args = match paren_args {
1779 Some(s) => serde_yaml::Value::String(s.to_string()),
1780 None => serde_yaml::Value::Mapping(args_map),
1781 };
1782
1783 Ok((args, on_deny, on_allow))
1784}
1785
1786fn parse_reaction_list(
1787 v: &serde_yaml::Value,
1788 source: &str,
1789 which: &str,
1790) -> Result<Vec<Step>, ParseError> {
1791 let list = v.as_sequence().ok_or_else(|| ParseError::Rule {
1792 rule: format!("{:?}", v),
1793 msg: format!("`{}:` must be a list of steps", which),
1794 })?;
1795 list.iter()
1796 .enumerate()
1797 .map(|(i, entry)| parse_step(entry, &format!("{}.{}[{}]", source, which, i)))
1798 .collect()
1799}
1800
1801fn extract_call_args(line: &str, name: &str) -> Option<String> {
1804 let line = line.trim();
1805 if !line.starts_with(name) {
1806 return None;
1807 }
1808 let after = &line[name.len()..];
1809 if !after.starts_with('(') {
1810 return None;
1811 }
1812 let bytes = after.as_bytes();
1814 let mut depth = 0;
1815 for (i, &b) in bytes.iter().enumerate() {
1816 match b {
1817 b'(' => depth += 1,
1818 b')' => {
1819 depth -= 1;
1820 if depth == 0 {
1821 if after[i + 1..].trim().is_empty() {
1823 return Some(after[1..i].to_string());
1824 }
1825 return None;
1826 }
1827 },
1828 _ => {},
1829 }
1830 }
1831 None
1832}
1833
1834pub fn parse_pipeline(src: &str) -> Result<Pipeline, ParseError> {
1844 let mut pipeline = Pipeline::new();
1845 for seg in split_top_level(src.trim(), b'|') {
1846 let seg = seg.trim();
1847 if seg.is_empty() {
1848 continue;
1849 }
1850 pipeline.push(parse_stage(seg)?);
1851 }
1852 Ok(pipeline)
1853}
1854
1855fn split_top_level(s: &str, delim: u8) -> Vec<&str> {
1857 let bytes = s.as_bytes();
1858 let mut out = Vec::new();
1859 let mut depth: i32 = 0;
1860 let mut in_quote: Option<u8> = None;
1861 let mut start = 0;
1862 for (i, &b) in bytes.iter().enumerate() {
1863 match (in_quote, b) {
1864 (Some(q), c) if c == q => in_quote = None,
1865 (Some(_), _) => {},
1866 (None, b'"') | (None, b'\'') => in_quote = Some(b),
1867 (None, b'(') | (None, b'[') => depth += 1,
1868 (None, b')') | (None, b']') => depth -= 1,
1869 (None, c) if c == delim && depth == 0 => {
1870 out.push(&s[start..i]);
1871 start = i + 1;
1872 },
1873 _ => {},
1874 }
1875 }
1876 out.push(&s[start..]);
1877 out
1878}
1879
1880fn parse_stage(src: &str) -> Result<Stage, ParseError> {
1881 let s = src.trim();
1882 let bad = |msg: &str| ParseError::Predicate {
1883 predicate: src.to_string(),
1884 msg: msg.to_string(),
1885 };
1886
1887 if let Some(stage) = try_parse_range(s) {
1889 return Ok(stage);
1890 }
1891
1892 let (head, args) = split_head_args(s).ok_or_else(|| bad("expected stage identifier"))?;
1895
1896 match (head, args.as_deref()) {
1897 ("str", None) => Ok(Stage::Type(TypeCheck::Str)),
1899 ("int", None) => Ok(Stage::Type(TypeCheck::Int)),
1900 ("bool", None) => Ok(Stage::Type(TypeCheck::Bool)),
1901 ("float", None) => Ok(Stage::Type(TypeCheck::Float)),
1902 ("email", None) => Ok(Stage::Type(TypeCheck::Email)),
1903 ("url", None) => Ok(Stage::Type(TypeCheck::Url)),
1904 ("uuid", None) => Ok(Stage::Type(TypeCheck::Uuid)),
1905 ("redact", None) => Ok(Stage::Redact { condition: None }),
1906 ("omit", None) => Ok(Stage::Omit),
1907 ("hash", None) => Ok(Stage::Hash),
1908 ("pii.redact", None) => Ok(Stage::Scan {
1910 kind: ScanKind::PiiRedact,
1911 }),
1912 ("pii.detect", None) => Ok(Stage::Scan {
1913 kind: ScanKind::PiiDetect,
1914 }),
1915 ("injection.scan", None) => Ok(Stage::Scan {
1916 kind: ScanKind::InjectionScan,
1917 }),
1918
1919 ("mask", Some(a)) => {
1921 let n: usize = a
1922 .trim()
1923 .parse()
1924 .map_err(|_| bad(&format!("mask(N) expects integer, got `{}`", a)))?;
1925 Ok(Stage::Mask { keep_last: n })
1926 },
1927 ("redact", Some(a)) => {
1928 let cond = parse_predicate(a).map_err(|e| ParseError::Predicate {
1930 predicate: src.to_string(),
1931 msg: format!("invalid redact() condition: {}", e),
1932 })?;
1933 Ok(Stage::Redact {
1934 condition: Some(cond),
1935 })
1936 },
1937 ("hash", Some(_)) => Err(bad("hash takes no arguments")),
1938 ("omit", Some(_)) => Err(bad(
1939 "omit takes no arguments — for conditional omit, use a policy rule predicate",
1940 )),
1941 ("len", Some(a)) => {
1942 let (min, max) = parse_range_inner(a)
1943 .ok_or_else(|| bad(&format!("len(...) expects N..M range, got `{}`", a)))?;
1944 let to_usize = |v: i64| -> Result<usize, ParseError> {
1945 if v < 0 {
1946 Err(bad("len bounds must be non-negative"))
1947 } else {
1948 Ok(v as usize)
1949 }
1950 };
1951 Ok(Stage::Length {
1952 min: min.map(to_usize).transpose()?,
1953 max: max.map(to_usize).transpose()?,
1954 })
1955 },
1956 ("enum", Some(a)) => {
1957 let values = split_top_level(a, b',')
1958 .into_iter()
1959 .map(|v| {
1960 let t = v.trim();
1961 if (t.starts_with('"') && t.ends_with('"'))
1963 || (t.starts_with('\'') && t.ends_with('\''))
1964 {
1965 t[1..t.len() - 1].to_string()
1966 } else {
1967 t.to_string()
1968 }
1969 })
1970 .filter(|s| !s.is_empty())
1971 .collect::<Vec<_>>();
1972 if values.is_empty() {
1973 return Err(bad("enum() requires at least one value"));
1974 }
1975 Ok(Stage::Enum { values })
1976 },
1977 ("regex", Some(a)) => {
1978 let pattern = a.trim();
1979 let pat = if (pattern.starts_with('"') && pattern.ends_with('"'))
1980 || (pattern.starts_with('\'') && pattern.ends_with('\''))
1981 {
1982 pattern[1..pattern.len() - 1].to_string()
1983 } else {
1984 pattern.to_string()
1985 };
1986 Ok(Stage::Regex { pattern: pat })
1987 },
1988 ("validate", Some(a)) => {
1989 Err(bad(&format!(
2003 "`validate({})` — named-validator dispatch is not implemented \
2004 in this build. Use `regex(\"pattern\")` for a named-regex \
2005 equivalent, or `plugin({})` for richer validation logic.",
2006 a.trim(),
2007 a.trim(),
2008 )))
2009 },
2010 ("plugin" | "run", Some(a)) => {
2012 let name = a.trim();
2013 if name.is_empty() {
2014 return Err(bad(&format!(
2018 "`{head}(...)`: plugin name must not be empty"
2019 )));
2020 }
2021 Ok(Stage::Plugin {
2022 name: name.to_string(),
2023 })
2024 },
2025 ("taint", Some(a)) => parse_taint(a, src),
2026
2027 (other, _) => Err(bad(&format!("unknown stage `{}`", other))),
2028 }
2029}
2030
2031fn try_parse_range(s: &str) -> Option<Stage> {
2033 if !s.contains("..") {
2034 return None;
2035 }
2036 let first = s.as_bytes().first().copied()?;
2038 if first.is_ascii_alphabetic() || first == b'_' {
2039 return None;
2040 }
2041 let (min, max) = parse_range_inner(s)?;
2042 Some(Stage::Range { min, max })
2043}
2044
2045fn parse_range_inner(s: &str) -> Option<(Option<i64>, Option<i64>)> {
2048 let dotdot = s.find("..")?;
2049 let left = s[..dotdot].trim();
2050 let right = s[dotdot + 2..].trim();
2051 let min = if left.is_empty() {
2052 None
2053 } else {
2054 Some(parse_numeric_with_suffix(left)?)
2055 };
2056 let max = if right.is_empty() {
2057 None
2058 } else {
2059 Some(parse_numeric_with_suffix(right)?)
2060 };
2061 if min.is_none() && max.is_none() {
2062 return None; }
2064 Some((min, max))
2065}
2066
2067fn parse_numeric_with_suffix(s: &str) -> Option<i64> {
2069 let s = s.trim();
2070 if s.is_empty() {
2071 return None;
2072 }
2073 let (num_part, mult) = match s.as_bytes().last().copied()? {
2074 b'k' | b'K' => (&s[..s.len() - 1], 1_000_i64),
2075 b'm' | b'M' => (&s[..s.len() - 1], 1_000_000_i64),
2076 _ => (s, 1_i64),
2077 };
2078 let n: i64 = num_part.parse().ok()?;
2079 n.checked_mul(mult)
2080}
2081
2082fn split_head_args(s: &str) -> Option<(&str, Option<String>)> {
2085 if let Some(open) = s.find('(') {
2086 let bytes = s.as_bytes();
2088 let mut depth = 0;
2089 let mut close = None;
2090 for (i, &b) in bytes.iter().enumerate().skip(open) {
2091 match b {
2092 b'(' => depth += 1,
2093 b')' => {
2094 depth -= 1;
2095 if depth == 0 {
2096 close = Some(i);
2097 break;
2098 }
2099 },
2100 _ => {},
2101 }
2102 }
2103 let close = close?;
2104 let head = s[..open].trim();
2105 if head.is_empty() {
2106 return None;
2107 }
2108 let args = s[open + 1..close].to_string();
2109 if s[close + 1..].trim().is_empty() {
2111 Some((head, Some(args)))
2112 } else {
2113 None
2114 }
2115 } else {
2116 let head = s.trim();
2117 if head.is_empty() {
2118 None
2119 } else {
2120 Some((head, None))
2121 }
2122 }
2123}
2124
2125fn parse_taint(args: &str, src: &str) -> Result<Stage, ParseError> {
2126 let parts = split_top_level(args, b',');
2128 if parts.is_empty() {
2129 return Err(ParseError::Predicate {
2130 predicate: src.to_string(),
2131 msg: "taint() requires at least a label".into(),
2132 });
2133 }
2134 let label = parts[0].trim().to_string();
2135 if label.is_empty() {
2136 return Err(ParseError::Predicate {
2137 predicate: src.to_string(),
2138 msg: "taint label must not be empty".into(),
2139 });
2140 }
2141
2142 let scopes = if parts.len() == 1 {
2143 vec![TaintScope::Session] } else {
2145 let scope_arg = parts[1..].join(",");
2146 let scope_arg = scope_arg.trim();
2147 if scope_arg.starts_with('[') && scope_arg.ends_with(']') {
2148 split_top_level(&scope_arg[1..scope_arg.len() - 1], b',')
2149 .into_iter()
2150 .map(|s| parse_taint_scope(s.trim(), src))
2151 .collect::<Result<Vec<_>, _>>()?
2152 } else {
2153 vec![parse_taint_scope(scope_arg, src)?]
2154 }
2155 };
2156
2157 Ok(Stage::Taint { label, scopes })
2158}
2159
2160fn parse_taint_scope(s: &str, src: &str) -> Result<TaintScope, ParseError> {
2161 match s {
2162 "session" => Ok(TaintScope::Session),
2163 "message" => Ok(TaintScope::Message),
2164 other => Err(ParseError::Predicate {
2165 predicate: src.to_string(),
2166 msg: format!(
2167 "unknown taint scope `{}` (expected `session` or `message`)",
2168 other
2169 ),
2170 }),
2171 }
2172}
2173
2174#[derive(Debug, Default, Deserialize)]
2188pub struct ConfigYaml {
2189 #[serde(default)]
2190 pub routes: HashMap<String, RouteYaml>,
2191
2192 #[serde(default)]
2194 pub plugins: Vec<PluginDeclaration>,
2195
2196 #[serde(flatten)]
2198 pub other: HashMap<String, serde_yaml::Value>,
2199}
2200
2201#[derive(Debug, Default, Deserialize)]
2202pub struct RouteYaml {
2203 #[serde(default)]
2206 pub policy: Vec<serde_yaml::Value>,
2207
2208 #[serde(default)]
2209 pub post_policy: Vec<serde_yaml::Value>,
2210
2211 #[serde(default)]
2213 pub args: HashMap<String, String>,
2214
2215 #[serde(default)]
2217 pub result: HashMap<String, String>,
2218
2219 #[serde(default)]
2223 pub plugins: HashMap<String, PluginOverride>,
2224
2225 #[serde(flatten)]
2227 pub other: HashMap<String, serde_yaml::Value>,
2228}
2229
2230#[derive(Debug, Default)]
2237pub struct CompiledConfig {
2238 pub routes: HashMap<String, CompiledRoute>,
2239 pub plugins: PluginRegistry,
2240}
2241
2242pub fn compile_config(yaml: &str) -> Result<CompiledConfig, ParseError> {
2252 let cfg: ConfigYaml = serde_yaml::from_str(yaml)?;
2253 let mut routes = HashMap::with_capacity(cfg.routes.len());
2254 for (route_key, raw) in cfg.routes {
2255 if let Some(route) = compile_route(&route_key, raw)? {
2256 routes.insert(route_key, route);
2257 }
2258 }
2259 let mut plugins = PluginRegistry::with_capacity(cfg.plugins.len());
2260 for decl in cfg.plugins {
2261 plugins.insert(decl.name.clone(), decl);
2264 }
2265 Ok(CompiledConfig { routes, plugins })
2266}
2267
2268fn compile_route(route_key: &str, raw: RouteYaml) -> Result<Option<CompiledRoute>, ParseError> {
2269 let has_apl = !raw.policy.is_empty()
2270 || !raw.post_policy.is_empty()
2271 || !raw.args.is_empty()
2272 || !raw.result.is_empty();
2273 if !has_apl {
2274 return Ok(None);
2275 }
2276 Ok(Some(compile_apl_blocks(route_key, raw)?))
2277}
2278
2279fn compile_apl_blocks(source: &str, raw: RouteYaml) -> Result<CompiledRoute, ParseError> {
2285 let mut route = CompiledRoute::new(source);
2286 for (i, entry) in raw.policy.iter().enumerate() {
2287 let step = parse_step(entry, &format!("{}.policy[{}]", source, i))?;
2288 route.policy.push(step_to_top_level_effect(step)?);
2289 }
2290 for (i, entry) in raw.post_policy.iter().enumerate() {
2291 let step = parse_step(entry, &format!("{}.post_policy[{}]", source, i))?;
2292 route.post_policy.push(step_to_top_level_effect(step)?);
2293 }
2294 for (field, chain) in &raw.args {
2295 let pipeline = parse_pipeline(chain).map_err(|e| ParseError::Rule {
2296 rule: format!("args.{}: {:?}", field, chain),
2297 msg: format!("{}", e),
2298 })?;
2299 route.args.push(FieldRule {
2300 field: field.clone(),
2301 pipeline,
2302 source: format!("{}.args.{}", source, field),
2303 });
2304 }
2305 for (field, chain) in &raw.result {
2306 let pipeline = parse_pipeline(chain).map_err(|e| ParseError::Rule {
2307 rule: format!("result.{}: {:?}", field, chain),
2308 msg: format!("{}", e),
2309 })?;
2310 route.result.push(FieldRule {
2311 field: field.clone(),
2312 pipeline,
2313 source: format!("{}.result.{}", source, field),
2314 });
2315 }
2316 route.plugin_overrides = raw.plugins;
2317 Ok(route)
2318}
2319
2320pub fn compile_policy_block_value(
2341 source: &str,
2342 block: &serde_yaml::Value,
2343) -> Result<CompiledRoute, ParseError> {
2344 if block.is_null() {
2345 return Ok(CompiledRoute::new(source));
2346 }
2347 let raw: RouteYaml = serde_yaml::from_value(block.clone())?;
2348 compile_apl_blocks(source, raw)
2349}
2350
2351#[cfg(test)]
2356mod tests {
2357 use super::*;
2358 use crate::attributes::AttributeBag;
2359 use crate::evaluator::Decision;
2360
2361 #[test]
2364 fn lex_basic() {
2365 let toks = Lexer::new("delegation.depth > 2").tokenize_all().unwrap();
2366 assert_eq!(
2367 toks,
2368 vec![
2369 Tok::Ident("delegation.depth".into()),
2370 Tok::Gt,
2371 Tok::IntLit(2),
2372 ]
2373 );
2374 }
2375
2376 #[test]
2377 fn lex_strings_both_quotes() {
2378 let a = Lexer::new(r#""double""#).tokenize_all().unwrap();
2379 let b = Lexer::new(r#"'single'"#).tokenize_all().unwrap();
2380 assert_eq!(a, vec![Tok::StringLit("double".into())]);
2381 assert_eq!(b, vec![Tok::StringLit("single".into())]);
2382 }
2383
2384 #[test]
2385 fn lex_keywords_vs_idents() {
2386 let toks = Lexer::new("require(role.hr) & authenticated")
2387 .tokenize_all()
2388 .unwrap();
2389 assert_eq!(
2390 toks,
2391 vec![
2392 Tok::Require,
2393 Tok::LParen,
2394 Tok::Ident("role.hr".into()),
2395 Tok::RParen,
2396 Tok::And,
2397 Tok::Ident("authenticated".into()),
2398 ]
2399 );
2400 }
2401
2402 #[test]
2403 fn lex_rejects_single_equals() {
2404 let err = Lexer::new("a = 1").tokenize_all().unwrap_err();
2405 assert!(format!("{}", err).contains("expected `==`"));
2406 }
2407
2408 #[test]
2411 fn pred_bare_identifier() {
2412 let e = parse_predicate("authenticated").unwrap();
2413 assert_eq!(
2414 e,
2415 Expression::Condition(Condition::IsTrue {
2416 key: "authenticated".into()
2417 })
2418 );
2419 }
2420
2421 #[test]
2422 fn pred_comparison() {
2423 let e = parse_predicate("delegation.depth > 2").unwrap();
2424 assert_eq!(
2425 e,
2426 Expression::Condition(Condition::Comparison {
2427 key: "delegation.depth".into(),
2428 op: CompareOp::Gt,
2429 value: Literal::Int(2),
2430 })
2431 );
2432 }
2433
2434 #[test]
2435 fn pred_contains() {
2436 let e = parse_predicate(r#"session.labels contains "PII""#).unwrap();
2437 assert_eq!(
2438 e,
2439 Expression::Condition(Condition::Comparison {
2440 key: "session.labels".into(),
2441 op: CompareOp::Contains,
2442 value: Literal::String("PII".into()),
2443 })
2444 );
2445 }
2446
2447 #[test]
2448 fn pred_precedence_or_lowest_and_middle_not_highest() {
2449 let e = parse_predicate("!a & b | c").unwrap();
2451 match e {
2452 Expression::Or(parts) => {
2453 assert_eq!(parts.len(), 2);
2454 match &parts[0] {
2455 Expression::And(_) => {},
2456 other => panic!("first OR branch should be AND, got {:?}", other),
2457 }
2458 },
2459 other => panic!("top-level should be OR, got {:?}", other),
2460 }
2461 }
2462
2463 #[test]
2464 fn pred_parens_override_precedence() {
2465 let e = parse_predicate("(role.finance | role.admin) & !delegated").unwrap();
2467 match e {
2468 Expression::And(parts) => {
2469 assert_eq!(parts.len(), 2);
2470 matches!(parts[0], Expression::Or(_));
2471 matches!(parts[1], Expression::Not(_));
2472 },
2473 other => panic!("expected top-level AND, got {:?}", other),
2474 }
2475 }
2476
2477 #[test]
2478 fn pred_require_rejected_as_predicate() {
2479 let err = parse_predicate("require(authenticated)").unwrap_err();
2482 assert!(format!("{}", err).contains("rule-level shorthand"));
2483 }
2484
2485 #[test]
2486 fn rule_require_single_arg_desugars_to_isfalse_and_deny() {
2487 let r = parse_rule("require(authenticated)", "test").unwrap();
2489 assert!(matches!(
2490 r.effects.as_slice(),
2491 [Effect::Deny {
2492 reason: None,
2493 code: None
2494 }]
2495 ));
2496 assert_eq!(
2497 r.condition,
2498 Expression::Condition(Condition::IsFalse {
2499 key: "authenticated".into()
2500 }),
2501 );
2502 }
2503
2504 #[test]
2505 fn rule_require_comma_is_and_desugars_to_or_of_isfalse() {
2506 let r = parse_rule("require(role.hr, perm.view_ssn)", "test").unwrap();
2509 assert_eq!(
2510 r.condition,
2511 Expression::Or(vec![
2512 Expression::Condition(Condition::IsFalse {
2513 key: "role.hr".into()
2514 }),
2515 Expression::Condition(Condition::IsFalse {
2516 key: "perm.view_ssn".into()
2517 }),
2518 ]),
2519 );
2520 }
2521
2522 #[test]
2523 fn rule_require_pipe_is_or_desugars_to_and_of_isfalse() {
2524 let r = parse_rule("require(role.finance | role.admin)", "test").unwrap();
2527 assert_eq!(
2528 r.condition,
2529 Expression::And(vec![
2530 Expression::Condition(Condition::IsFalse {
2531 key: "role.finance".into()
2532 }),
2533 Expression::Condition(Condition::IsFalse {
2534 key: "role.admin".into()
2535 }),
2536 ]),
2537 );
2538 }
2539
2540 #[test]
2541 fn rule_require_mixed_rejected() {
2542 let err = parse_rule("require(a, b | c)", "test").unwrap_err();
2543 assert!(format!("{}", err).contains("cannot mix"));
2544 }
2545
2546 #[test]
2547 fn pred_eq_with_ident_rhs_rejected_with_in_hint() {
2548 let err = parse_predicate("subject.type == allowed_types").unwrap_err();
2551 let msg = format!("{}", err);
2552 assert!(msg.contains("RHS-as-identifier"));
2553 assert!(msg.contains("set membership use"));
2554 }
2555
2556 #[test]
2557 fn pred_in_set_basic() {
2558 let e = parse_predicate("subject.type in allowed_types").unwrap();
2559 assert_eq!(
2560 e,
2561 Expression::Condition(Condition::InSet {
2562 value_key: "subject.type".into(),
2563 set_key: "allowed_types".into(),
2564 negate: false,
2565 }),
2566 );
2567 }
2568
2569 #[test]
2570 fn pred_not_in_set() {
2571 let e = parse_predicate("subject.type not in blocked_types").unwrap();
2572 assert_eq!(
2573 e,
2574 Expression::Condition(Condition::InSet {
2575 value_key: "subject.type".into(),
2576 set_key: "blocked_types".into(),
2577 negate: true,
2578 }),
2579 );
2580 }
2581
2582 #[test]
2583 fn pred_exists_basic() {
2584 let e = parse_predicate("exists(args.amount)").unwrap();
2585 assert_eq!(
2586 e,
2587 Expression::Condition(Condition::Exists {
2588 key: "args.amount".into()
2589 }),
2590 );
2591 }
2592
2593 #[test]
2594 fn pred_exists_inside_compound() {
2595 let e = parse_predicate("exists(args.amount) & args.amount > 0").unwrap();
2597 match e {
2598 Expression::And(parts) => {
2599 assert_eq!(parts.len(), 2);
2600 assert_eq!(
2601 parts[0],
2602 Expression::Condition(Condition::Exists {
2603 key: "args.amount".into()
2604 }),
2605 );
2606 },
2607 other => panic!("expected And, got {:?}", other),
2608 }
2609 }
2610
2611 #[test]
2612 fn pred_exists_requires_paren_and_ident() {
2613 assert!(parse_predicate("exists").is_err());
2614 assert!(parse_predicate("exists()").is_err());
2615 assert!(parse_predicate("exists(authenticated").is_err());
2616 }
2617
2618 #[test]
2619 fn pred_trailing_tokens_rejected() {
2620 let err = parse_predicate("a b").unwrap_err();
2621 assert!(format!("{}", err).contains("trailing"));
2622 }
2623
2624 #[test]
2627 fn rule_predicate_action_form() {
2628 let r = parse_rule("delegation.depth > 2: deny", "test").unwrap();
2629 match r.effects.as_slice() {
2630 [Effect::Deny { .. }] => {},
2631 other => panic!("expected [Deny], got {:?}", other),
2632 }
2633 match r.condition {
2634 Expression::Condition(Condition::Comparison { .. }) => {},
2635 other => panic!("expected Comparison, got {:?}", other),
2636 }
2637 }
2638
2639 #[test]
2640 fn rule_predicate_only_defaults_to_deny() {
2641 let r = parse_rule("!authenticated", "test").unwrap();
2643 assert!(matches!(r.effects.as_slice(), [Effect::Deny { .. }]));
2644 }
2645
2646 #[test]
2647 fn rule_explicit_allow() {
2648 let r = parse_rule("role.admin: allow", "test").unwrap();
2649 assert!(matches!(r.effects.as_slice(), [Effect::Allow]));
2650 }
2651
2652 #[test]
2653 fn rule_bare_action_unconditional() {
2654 let r = parse_rule("deny", "test").unwrap();
2657 assert_eq!(r.condition, Expression::Always);
2658 assert!(matches!(
2659 r.effects.as_slice(),
2660 [Effect::Deny {
2661 reason: None,
2662 code: None
2663 }]
2664 ));
2665
2666 let r = parse_rule("allow", "test").unwrap();
2667 assert_eq!(r.condition, Expression::Always);
2668 assert!(matches!(r.effects.as_slice(), [Effect::Allow]));
2669 }
2670
2671 #[test]
2672 fn rule_bare_deny_call_carries_reason_and_code() {
2673 let r = parse_rule("deny('nope')", "test").unwrap();
2677 assert_eq!(r.condition, Expression::Always);
2678 match r.effects.as_slice() {
2679 [Effect::Deny {
2680 reason: Some(reason),
2681 code: None,
2682 }] => assert_eq!(reason, "nope"),
2683 other => panic!(
2684 "expected [Deny{{reason: Some, code: None}}], got {:?}",
2685 other
2686 ),
2687 }
2688
2689 let r = parse_rule("deny('nope', 'cel.policy')", "test").unwrap();
2690 assert_eq!(r.condition, Expression::Always);
2691 match r.effects.as_slice() {
2692 [Effect::Deny {
2693 reason: Some(reason),
2694 code: Some(code),
2695 }] => {
2696 assert_eq!(reason, "nope");
2697 assert_eq!(code, "cel.policy");
2698 },
2699 other => panic!("expected [Deny{{reason, code}}], got {:?}", other),
2700 }
2701 }
2702
2703 #[test]
2704 fn rule_malformed_bare_deny_call_errors() {
2705 let err = parse_rule("deny(unquoted)", "test").unwrap_err();
2708 assert!(
2709 matches!(err, ParseError::Rule { .. }),
2710 "expected ParseError::Rule, got {:?}",
2711 err
2712 );
2713 }
2714
2715 #[test]
2716 fn rule_step_kinds_rejected_clearly() {
2717 for s in [
2718 "plugin(rate_limiter)",
2719 "cedar:(action: read)",
2720 "opa(path)",
2721 "taint(audit)",
2722 ] {
2723 let err = parse_rule(s, "test").unwrap_err();
2724 assert!(
2725 matches!(err, ParseError::UnsupportedStep { .. }),
2726 "expected UnsupportedStep for `{}`, got {:?}",
2727 s,
2728 err
2729 );
2730 }
2731 }
2732
2733 #[test]
2734 fn rule_deny_with_unquoted_arg_rejected() {
2735 let err = parse_rule(r#"authenticated: deny "go away""#, "test").unwrap_err();
2740 assert!(format!("{}", err).contains("unsupported action"));
2741 }
2742
2743 #[test]
2744 fn rule_deny_with_quoted_reason_accepted() {
2745 let r = parse_rule(r#"delegation.depth > 2: deny('too deep')"#, "test").unwrap();
2748 assert!(matches!(
2749 r.effects.as_slice(),
2750 [Effect::Deny { reason: Some(s), code: None }] if s == "too deep"
2751 ));
2752 }
2753
2754 #[test]
2755 fn rule_deny_with_reason_and_code_accepted() {
2756 let r = parse_rule(
2759 r#"delegation.depth > 2: deny('too deep', 'delegation.depth_exceeded')"#,
2760 "test",
2761 )
2762 .unwrap();
2763 match r.effects.as_slice() {
2764 [Effect::Deny {
2765 reason: Some(reason),
2766 code: Some(code),
2767 }] => {
2768 assert_eq!(reason, "too deep");
2769 assert_eq!(code, "delegation.depth_exceeded");
2770 },
2771 other => panic!("expected Deny with reason+code, got {:?}", other),
2772 }
2773 }
2774
2775 #[test]
2776 fn rule_deny_with_too_many_args_rejected() {
2777 let err = parse_rule(r#"x: deny('a', 'b', 'c')"#, "test").unwrap_err();
2779 assert!(format!("{}", err).contains("at most two args"));
2780 }
2781
2782 #[test]
2783 fn rule_deny_with_unquoted_args_in_call_rejected() {
2784 let err = parse_rule(r#"x: deny(bare, identifier)"#, "test").unwrap_err();
2786 assert!(format!("{}", err).contains("expected a quoted string"));
2787 }
2788
2789 fn parse_step_yaml(yaml: &str) -> Result<Step, ParseError> {
2792 let v: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
2793 parse_step(&v, "test")
2794 }
2795
2796 #[test]
2797 fn when_do_single_effect_deny() {
2798 let step = parse_step_yaml("when: delegation.depth > 2\ndo: deny").unwrap();
2800 match step {
2801 Step::Rule(rule) => {
2802 assert!(matches!(
2803 rule.condition,
2804 Expression::Condition(Condition::Comparison { .. })
2805 ));
2806 assert!(matches!(
2807 rule.effects.as_slice(),
2808 [Effect::Deny {
2809 reason: None,
2810 code: None
2811 }]
2812 ));
2813 },
2814 other => panic!("expected Step::Rule, got {:?}", other),
2815 }
2816 }
2817
2818 #[test]
2819 fn when_do_single_effect_deny_with_reason_and_code() {
2820 let step = parse_step_yaml(
2822 "when: delegation.depth > 2\ndo: deny('too deep', 'delegation.depth_exceeded')",
2823 )
2824 .unwrap();
2825 let Step::Rule(rule) = step else {
2826 panic!("expected Step::Rule");
2827 };
2828 match rule.effects.as_slice() {
2829 [Effect::Deny {
2830 reason: Some(r),
2831 code: Some(c),
2832 }] => {
2833 assert_eq!(r, "too deep");
2834 assert_eq!(c, "delegation.depth_exceeded");
2835 },
2836 other => panic!("expected Deny+reason+code, got {:?}", other),
2837 }
2838 }
2839
2840 #[test]
2841 fn when_do_multi_effect_list() {
2842 let yaml = r#"
2845when: "!role.hr"
2846do:
2847 - "plugin(audit_logger)"
2848 - "taint(unauth, session)"
2849 - "deny('refused', 'role.hr_required')"
2850"#;
2851 let step = parse_step_yaml(yaml).unwrap();
2852 let Step::Rule(rule) = step else {
2853 panic!("expected Step::Rule");
2854 };
2855 assert_eq!(rule.effects.len(), 3);
2856 assert!(matches!(rule.effects[0], Effect::Plugin { ref name } if name == "audit_logger"));
2857 assert!(matches!(
2858 rule.effects[1],
2859 Effect::Taint { ref label, .. } if label == "unauth"
2860 ));
2861 match &rule.effects[2] {
2862 Effect::Deny {
2863 reason: Some(r),
2864 code: Some(c),
2865 } => {
2866 assert_eq!(r, "refused");
2867 assert_eq!(c, "role.hr_required");
2868 },
2869 other => panic!("expected Deny+reason+code, got {:?}", other),
2870 }
2871 }
2872
2873 #[test]
2874 fn when_do_key_order_does_not_matter() {
2875 let step = parse_step_yaml("do: deny\nwhen: delegation.depth > 2").unwrap();
2877 assert!(matches!(step, Step::Rule(_)));
2878 }
2879
2880 #[test]
2881 fn when_do_with_unknown_key_rejected() {
2882 let err = parse_step_yaml("when: x\ndo: deny\nwhne: typo").unwrap_err();
2884 assert!(format!("{}", err).contains("unexpected key"));
2885 }
2886
2887 #[test]
2888 fn when_do_empty_do_list_rejected() {
2889 let err = parse_step_yaml("when: x\ndo: []").unwrap_err();
2892 assert!(format!("{}", err).contains("no effects"));
2893 }
2894
2895 #[test]
2898 fn shorthand_multi_effect_map() {
2899 let yaml = r#"
2902"!role.hr":
2903 - "plugin(audit_logger)"
2904 - "deny('unauthorized')"
2905"#;
2906 let step = parse_step_yaml(yaml).unwrap();
2907 let Step::Rule(rule) = step else {
2908 panic!("expected Step::Rule");
2909 };
2910 assert_eq!(rule.effects.len(), 2);
2911 assert!(matches!(rule.effects[0], Effect::Plugin { ref name } if name == "audit_logger"));
2912 assert!(matches!(
2913 rule.effects[1],
2914 Effect::Deny { reason: Some(ref r), code: None } if r == "unauthorized"
2915 ));
2916 }
2917
2918 #[test]
2919 fn shorthand_multi_effect_map_with_nested_delegate() {
2920 let yaml = r#"
2923"role.hr":
2924 - delegate:
2925 plugin: workday-oauth
2926 config:
2927 audience: workday-api
2928 - "plugin(audit_logger)"
2929"#;
2930 let step = parse_step_yaml(yaml).unwrap();
2931 let Step::Rule(rule) = step else {
2932 panic!("expected Step::Rule");
2933 };
2934 assert_eq!(rule.effects.len(), 2);
2935 assert!(matches!(rule.effects[0], Effect::Delegate(_)));
2936 assert!(matches!(rule.effects[1], Effect::Plugin { .. }));
2937 }
2938
2939 #[test]
2940 fn cedar_with_list_body_still_parses_as_pdp() {
2941 let err = parse_step_yaml("cedar: [oh no]").unwrap_err();
2952 assert!(format!("{}", err).contains("body must be a map"));
2955 }
2956
2957 #[test]
2958 fn shorthand_multi_effect_empty_list_rejected() {
2959 let err = parse_step_yaml(r#""x": []"#).unwrap_err();
2960 assert!(format!("{}", err).contains("no effects"));
2961 }
2962
2963 #[test]
2966 fn when_do_with_field_op_result_redact() {
2967 let yaml = r#"
2970when: "!perm.view_ssn"
2971do:
2972 - "plugin(audit_logger)"
2973 - "result.salary | redact"
2974"#;
2975 let step = parse_step_yaml(yaml).unwrap();
2976 let Step::Rule(rule) = step else {
2977 panic!("expected Step::Rule");
2978 };
2979 assert_eq!(rule.effects.len(), 2);
2980 assert!(matches!(rule.effects[0], Effect::Plugin { .. }));
2981 match &rule.effects[1] {
2982 Effect::FieldOp { path, stages } => {
2983 assert_eq!(path, "result.salary");
2984 assert_eq!(stages.len(), 1, "single `redact` stage");
2985 },
2986 other => panic!("expected FieldOp, got {:?}", other),
2987 }
2988 }
2989
2990 #[test]
2991 fn when_do_with_field_op_args_mask() {
2992 let yaml = r#"
2994when: role.support
2995do: "args.card_number | mask(4)"
2996"#;
2997 let step = parse_step_yaml(yaml).unwrap();
2998 let Step::Rule(rule) = step else {
2999 panic!("expected Step::Rule");
3000 };
3001 match &rule.effects[..] {
3002 [Effect::FieldOp { path, stages }] => {
3003 assert_eq!(path, "args.card_number");
3004 assert_eq!(stages.len(), 1);
3005 },
3006 other => panic!("expected single FieldOp, got {:?}", other),
3007 }
3008 }
3009
3010 #[test]
3011 fn when_do_with_chained_field_op() {
3012 let yaml = r#"
3015when: role.support
3016do: "args.card_number | str | mask(4)"
3017"#;
3018 let step = parse_step_yaml(yaml).unwrap();
3019 let Step::Rule(rule) = step else {
3020 panic!("expected Step::Rule");
3021 };
3022 match &rule.effects[..] {
3023 [Effect::FieldOp { path, stages }] => {
3024 assert_eq!(path, "args.card_number");
3025 assert_eq!(stages.len(), 2, "two-stage chain");
3026 },
3027 other => panic!("expected single FieldOp, got {:?}", other),
3028 }
3029 }
3030
3031 #[test]
3032 fn field_stage_run_aliases_plugin() {
3033 let yaml = r#"
3036when: role.support
3037do: "args.card_number | run(luhn)"
3038"#;
3039 let step = parse_step_yaml(yaml).unwrap();
3040 let Step::Rule(rule) = step else {
3041 panic!("expected Step::Rule");
3042 };
3043 match &rule.effects[..] {
3044 [Effect::FieldOp { path, stages }] => {
3045 assert_eq!(path, "args.card_number");
3046 match &stages[..] {
3047 [Stage::Plugin { name }] => assert_eq!(name, "luhn"),
3048 other => panic!("expected [Stage::Plugin], got {:?}", other),
3049 }
3050 },
3051 other => panic!("expected single FieldOp, got {:?}", other),
3052 }
3053 }
3054
3055 #[test]
3056 fn field_stage_plugin_empty_name_is_rejected() {
3057 for verb in ["plugin", "run"] {
3062 let err = parse_stage(&format!("{verb}()")).expect_err("empty name must error");
3063 let msg = format!("{err}");
3064 assert!(
3065 msg.contains(verb) && msg.contains("must not be empty"),
3066 "{verb}(): expected verb-named empty-name error, got: {msg}"
3067 );
3068 }
3069 }
3070
3071 #[test]
3072 fn field_op_invalid_path_falls_through() {
3073 let yaml = r#"do: "role.hr | redact""#;
3078 let _ = parse_step_yaml(&format!("when: true\n{}", yaml));
3079 let step = parse_step_yaml("when: true\ndo: \"role.hr | redact\"");
3084 match step {
3085 Ok(Step::Rule(rule)) => {
3086 assert!(
3087 !matches!(rule.effects.as_slice(), [Effect::FieldOp { .. }]),
3088 "bare `role.hr` must NOT parse as a FieldOp path"
3089 );
3090 },
3091 Err(_) => {}, other => panic!("unexpected: {:?}", other),
3093 }
3094 }
3095
3096 #[test]
3097 fn field_op_empty_chain_rejected() {
3098 let yaml = r#"when: true
3100do: "args.x | ""#;
3101 let _ = parse_step_yaml(yaml); }
3103
3104 #[test]
3105 fn shorthand_multi_effect_with_field_op() {
3106 let yaml = r#"
3108"!perm.view_ssn":
3109 - "plugin(audit_logger)"
3110 - "result.ssn | redact"
3111"#;
3112 let step = parse_step_yaml(yaml).unwrap();
3113 let Step::Rule(rule) = step else {
3114 panic!("expected Step::Rule");
3115 };
3116 assert_eq!(rule.effects.len(), 2);
3117 assert!(matches!(rule.effects[1], Effect::FieldOp { .. }));
3118 }
3119
3120 #[test]
3121 fn find_top_level_pipe_skips_inside_parens() {
3122 assert_eq!(find_top_level_pipe("args.x | mask(4)"), Some(7));
3125 assert_eq!(find_top_level_pipe("validate(luhn)"), None);
3126 assert_eq!(find_top_level_pipe(r#"args.x | mask("a|b")"#), Some(7));
3127 assert_eq!(find_top_level_pipe("mask(a|b)"), None);
3129 }
3130
3131 #[test]
3134 fn top_level_sequential() {
3135 let yaml = r#"
3137sequential:
3138 - "plugin(rate_limiter)"
3139 - "plugin(audit_logger)"
3140"#;
3141 let step = parse_step_yaml(yaml).unwrap();
3142 let Step::Rule(rule) = step else {
3143 panic!("expected Rule");
3144 };
3145 assert!(matches!(rule.condition, Expression::Always));
3146 match rule.effects.as_slice() {
3147 [Effect::Sequential(inner)] => {
3148 assert_eq!(inner.len(), 2);
3149 assert!(matches!(inner[0], Effect::Plugin { .. }));
3150 assert!(matches!(inner[1], Effect::Plugin { .. }));
3151 },
3152 other => panic!("expected single Sequential effect, got {:?}", other),
3153 }
3154 }
3155
3156 #[test]
3157 fn top_level_parallel() {
3158 let yaml = r#"
3159parallel:
3160 - "plugin(pii_scanner)"
3161 - "plugin(nemo_guardrails)"
3162"#;
3163 let step = parse_step_yaml(yaml).unwrap();
3164 let Step::Rule(rule) = step else {
3165 panic!("expected Rule");
3166 };
3167 match rule.effects.as_slice() {
3168 [Effect::Parallel(inner)] => {
3169 assert_eq!(inner.len(), 2);
3170 },
3171 other => panic!("expected single Parallel effect, got {:?}", other),
3172 }
3173 }
3174
3175 #[test]
3176 fn parallel_inside_do_body() {
3177 let yaml = r#"
3180when: args.include_ssn == true
3181do:
3182 parallel:
3183 - "plugin(pii_scanner)"
3184 - "plugin(nemo_guardrails)"
3185"#;
3186 let step = parse_step_yaml(yaml).unwrap();
3187 let Step::Rule(rule) = step else {
3188 panic!("expected Rule");
3189 };
3190 match rule.effects.as_slice() {
3191 [Effect::Parallel(inner)] => assert_eq!(inner.len(), 2),
3192 other => panic!("expected Parallel in do:, got {:?}", other),
3193 }
3194 }
3195
3196 #[test]
3197 fn parallel_rejects_field_op_at_parse_time() {
3198 let yaml = r#"
3200parallel:
3201 - "plugin(audit)"
3202 - "args.ssn | redact"
3203"#;
3204 let err = parse_step_yaml(yaml).unwrap_err();
3205 assert!(format!("{}", err).contains("mutation"), "got: {}", err);
3206 }
3207
3208 #[test]
3209 fn parallel_rejects_delegate_at_parse_time() {
3210 let yaml = r#"
3211parallel:
3212 - "plugin(audit)"
3213 - "delegate(workday)"
3214"#;
3215 let err = parse_step_yaml(yaml).unwrap_err();
3216 assert!(format!("{}", err).contains("mutation"));
3217 }
3218
3219 #[test]
3220 fn sequential_allows_mutations() {
3221 let yaml = r#"
3223sequential:
3224 - "args.ssn | redact"
3225 - "plugin(audit)"
3226"#;
3227 let step = parse_step_yaml(yaml).unwrap();
3228 let Step::Rule(rule) = step else {
3229 panic!("expected Rule")
3230 };
3231 match rule.effects.as_slice() {
3232 [Effect::Sequential(inner)] => {
3233 assert!(matches!(inner[0], Effect::FieldOp { .. }));
3234 assert!(matches!(inner[1], Effect::Plugin { .. }));
3235 },
3236 other => panic!("got {:?}", other),
3237 }
3238 }
3239
3240 #[test]
3241 fn parallel_empty_list_rejected() {
3242 let err = parse_step_yaml("parallel: []").unwrap_err();
3243 assert!(format!("{}", err).contains("empty"));
3244 }
3245
3246 #[test]
3247 fn sequential_empty_list_rejected() {
3248 let err = parse_step_yaml("sequential: []").unwrap_err();
3249 assert!(format!("{}", err).contains("empty"));
3250 }
3251
3252 #[test]
3253 fn nested_orchestration() {
3254 let yaml = r#"
3257sequential:
3258 - "plugin(rate_limiter)"
3259 - parallel:
3260 - "plugin(pii_scanner)"
3261 - "plugin(nemo)"
3262"#;
3263 let step = parse_step_yaml(yaml).unwrap();
3264 let Step::Rule(rule) = step else {
3265 panic!("expected Rule")
3266 };
3267 let Effect::Sequential(outer) = &rule.effects[0] else {
3268 panic!("expected Sequential");
3269 };
3270 assert_eq!(outer.len(), 2);
3271 assert!(matches!(outer[0], Effect::Plugin { .. }));
3272 match &outer[1] {
3273 Effect::Parallel(inner) => assert_eq!(inner.len(), 2),
3274 other => panic!("expected nested Parallel, got {:?}", other),
3275 }
3276 }
3277
3278 #[test]
3281 fn split_respects_quotes_and_parens() {
3282 let r = parse_rule(r#"session.labels contains "a:b": deny"#, "test").unwrap();
3284 assert!(matches!(r.effects.as_slice(), [Effect::Deny { .. }]));
3285 if let Expression::Condition(Condition::Comparison { value, .. }) = r.condition {
3286 assert_eq!(value, Literal::String("a:b".into()));
3287 } else {
3288 panic!("expected Comparison");
3289 }
3290 }
3291
3292 #[test]
3295 fn compile_simple_route() {
3296 let yaml = r#"
3297routes:
3298 get_compensation:
3299 policy:
3300 - "require(authenticated)"
3301 - "require(role.hr | role.finance)"
3302 - "delegation.depth > 2 & include_ssn: deny"
3303"#;
3304 let routes = compile_config(yaml).unwrap().routes;
3305 let route = routes.get("get_compensation").expect("route missing");
3306 assert_eq!(route.policy.len(), 3);
3307 assert!(route
3308 .declared_phases()
3309 .contains(crate::rules::Phase::Policy));
3310 }
3311
3312 #[test]
3313 fn compile_omits_routes_without_apl_blocks() {
3314 let yaml = r#"
3319routes:
3320 legacy:
3321 priority: 50
3322 apl_route:
3323 policy:
3324 - "require(authenticated)"
3325"#;
3326 let routes = compile_config(yaml).unwrap().routes;
3327 assert!(routes.contains_key("apl_route"));
3328 assert!(
3329 !routes.contains_key("legacy"),
3330 "legacy route should be omitted, not compiled"
3331 );
3332 }
3333
3334 #[test]
3335 fn compile_unknown_top_level_keys_ignored() {
3336 let yaml = r#"
3337version: "0.1"
3338policy_evaluator:
3339 kind: apl
3340plugins:
3341 - name: rate_limiter
3342 kind: native
3343imports:
3344 - "./shared.yaml"
3345routes:
3346 ping:
3347 policy:
3348 - "require(authenticated)"
3349"#;
3350 let routes = compile_config(yaml).unwrap().routes;
3351 assert!(routes.contains_key("ping"));
3352 }
3353
3354 #[test]
3355 fn compile_propagates_rule_errors_with_source() {
3356 let yaml = r#"
3357routes:
3358 bad:
3359 policy:
3360 - "subject.id == garbage_ident"
3361"#;
3362 let err = compile_config(yaml).unwrap_err();
3363 let msg = format!("{}", err);
3365 assert!(
3366 msg.contains("RHS-as-identifier") || msg.contains("garbage_ident"),
3367 "error message should reference the failure: {}",
3368 msg,
3369 );
3370 }
3371
3372 #[test]
3373 fn compile_plugin_step_string_form() {
3374 let yaml = r#"
3375routes:
3376 rate_limited:
3377 policy:
3378 - "plugin(rate_limiter)"
3379"#;
3380 let routes = compile_config(yaml).unwrap().routes;
3381 let route = routes.get("rate_limited").unwrap();
3382 assert_eq!(route.policy.len(), 1);
3383 match &route.policy[0] {
3384 Effect::Plugin { name } => assert_eq!(name, "rate_limiter"),
3385 other => panic!("expected Effect::Plugin, got {:?}", other),
3386 }
3387 }
3388
3389 #[test]
3390 fn compile_run_step_string_form_aliases_plugin() {
3391 let yaml = r#"
3394routes:
3395 rate_limited:
3396 policy:
3397 - "run(rate_limiter)"
3398"#;
3399 let routes = compile_config(yaml).unwrap().routes;
3400 let route = routes.get("rate_limited").unwrap();
3401 assert_eq!(route.policy.len(), 1);
3402 match &route.policy[0] {
3403 Effect::Plugin { name } => assert_eq!(name, "rate_limiter"),
3404 other => panic!("expected Effect::Plugin, got {:?}", other),
3405 }
3406 }
3407
3408 #[test]
3409 fn parse_step_run_is_plugin_alias() {
3410 for s in ["run(audit-log)", "plugin(audit-log)"] {
3411 let step = parse_step(&serde_yaml::Value::String(s.to_string()), "test").unwrap();
3412 match step {
3413 crate::step::Step::Plugin { name } => assert_eq!(name, "audit-log", "{s}"),
3414 other => panic!("expected Step::Plugin for `{s}`, got {other:?}"),
3415 }
3416 }
3417 let err = parse_step(&serde_yaml::Value::String("run()".to_string()), "test").unwrap_err();
3419 assert!(
3420 format!("{err}").contains("run("),
3421 "error should name `run(...)`: {err}"
3422 );
3423 }
3424
3425 #[test]
3426 fn compile_taint_step_string_form() {
3427 let yaml = r#"
3428routes:
3429 audit_marked:
3430 policy:
3431 - "taint(audit, session)"
3432"#;
3433 let routes = compile_config(yaml).unwrap().routes;
3434 let route = routes.get("audit_marked").unwrap();
3435 match &route.policy[0] {
3436 Effect::Taint { label, scopes } => {
3437 assert_eq!(label, "audit");
3438 assert_eq!(scopes, &vec![TaintScope::Session]);
3439 },
3440 other => panic!("expected Effect::Taint, got {:?}", other),
3441 }
3442 }
3443
3444 #[test]
3445 fn compile_pdp_call_cedar_map_form() {
3446 let yaml = r#"
3448routes:
3449 authz_check:
3450 policy:
3451 - cedar:
3452 action: read
3453 resource: employee
3454 on_deny:
3455 - deny
3456 on_allow:
3457 - "plugin(audit_logger)"
3458"#;
3459 let routes = compile_config(yaml).unwrap().routes;
3460 let route = routes.get("authz_check").unwrap();
3461 match &route.policy[0] {
3462 Effect::Pdp {
3463 call,
3464 on_deny,
3465 on_allow,
3466 } => {
3467 assert_eq!(call.dialect, PdpDialect::Cedar);
3468 let args_map = call.args.as_mapping().expect("cedar args should be a map");
3471 assert!(args_map.contains_key(serde_yaml::Value::String("action".into())));
3472 assert!(args_map.contains_key(serde_yaml::Value::String("resource".into())));
3473 assert!(!args_map.contains_key(serde_yaml::Value::String("on_deny".into())));
3474 assert_eq!(on_deny.len(), 1);
3475 assert_eq!(on_allow.len(), 1);
3476 },
3477 other => panic!("expected Effect::Pdp, got {:?}", other),
3478 }
3479 }
3480
3481 #[test]
3482 fn compile_pdp_call_cel_map_form() {
3483 let yaml = r#"
3486routes:
3487 authz_check:
3488 policy:
3489 - cel:
3490 expr: "subject.id == 'alice' && delegation.depth <= 2"
3491 on_deny:
3492 - deny
3493"#;
3494 let routes = compile_config(yaml).unwrap().routes;
3495 let route = routes.get("authz_check").unwrap();
3496 match &route.policy[0] {
3497 Effect::Pdp {
3498 call,
3499 on_deny,
3500 on_allow,
3501 } => {
3502 assert_eq!(call.dialect, PdpDialect::Cel);
3503 let args_map = call.args.as_mapping().expect("cel args should be a map");
3504 assert!(args_map.contains_key(serde_yaml::Value::String("expr".into())));
3505 assert!(!args_map.contains_key(serde_yaml::Value::String("on_deny".into())));
3507 assert_eq!(on_deny.len(), 1);
3508 assert_eq!(on_allow.len(), 0);
3509 },
3510 other => panic!("expected Effect::Pdp, got {:?}", other),
3511 }
3512 }
3513
3514 #[test]
3515 fn compile_pdp_call_opa_paren_form() {
3516 let yaml = r#"
3518routes:
3519 opa_check:
3520 policy:
3521 - 'opa("hr/compensation/deny"):':
3522 on_deny:
3523 - deny
3524"#;
3525 let routes = compile_config(yaml).unwrap().routes;
3526 let route = routes.get("opa_check").unwrap();
3527 match &route.policy[0] {
3528 Effect::Pdp { call, on_deny, .. } => {
3529 assert_eq!(call.dialect, PdpDialect::Opa);
3530 assert!(call.args.as_str().unwrap().contains("hr/compensation/deny"));
3532 assert_eq!(on_deny.len(), 1);
3533 },
3534 other => panic!("expected Effect::Pdp, got {:?}", other),
3535 }
3536 }
3537
3538 #[test]
3539 fn compile_pdp_unknown_dialect_becomes_custom() {
3540 let yaml = r#"
3541routes:
3542 custom_pdp:
3543 policy:
3544 - my_engine:
3545 on_deny: [deny]
3546"#;
3547 let routes = compile_config(yaml).unwrap().routes;
3548 match &routes.get("custom_pdp").unwrap().policy[0] {
3549 Effect::Pdp { call, .. } => {
3550 assert_eq!(call.dialect, PdpDialect::Custom("my_engine".into()));
3551 },
3552 other => panic!("expected Pdp, got {:?}", other),
3553 }
3554 }
3555
3556 #[tokio::test]
3559 async fn end_to_end_hr_compensation() {
3560 let yaml = r#"
3561routes:
3562 get_compensation:
3563 policy:
3564 - "require(authenticated)"
3565 - "require(role.hr | role.finance)"
3566 - "delegation.depth > 2: deny"
3567"#;
3568 let routes = compile_config(yaml).unwrap().routes;
3569 let route = routes.get("get_compensation").unwrap();
3570
3571 let pdp: std::sync::Arc<dyn crate::PdpResolver> = std::sync::Arc::new(NullPdpResolver);
3572 let plugins: std::sync::Arc<dyn crate::PluginInvoker> =
3573 std::sync::Arc::new(NullPluginInvoker);
3574 let delegations: std::sync::Arc<dyn crate::DelegationInvoker> =
3575 std::sync::Arc::new(crate::NoopDelegationInvoker);
3576
3577 let mut bag = AttributeBag::new();
3579 bag.set("authenticated", true);
3580 bag.set("role.hr", true);
3581 bag.set("delegation.depth", 1_i64);
3582 assert_eq!(
3583 crate::evaluate_effects(
3584 &route.policy,
3585 &mut bag,
3586 &pdp,
3587 &plugins,
3588 &delegations,
3589 crate::DispatchPhase::Pre,
3590 &mut crate::route::RoutePayload::new(serde_json::Value::Null)
3591 )
3592 .await
3593 .decision,
3594 Decision::Allow,
3595 );
3596
3597 bag.set("delegation.depth", 3_i64);
3599 match crate::evaluate_effects(
3600 &route.policy,
3601 &mut bag,
3602 &pdp,
3603 &plugins,
3604 &delegations,
3605 crate::DispatchPhase::Pre,
3606 &mut crate::route::RoutePayload::new(serde_json::Value::Null),
3607 )
3608 .await
3609 .decision
3610 {
3611 Decision::Deny { rule_source, .. } => {
3612 assert!(
3613 rule_source.contains("policy[2]"),
3614 "expected policy[2], got {}",
3615 rule_source
3616 );
3617 },
3618 d => panic!("expected Deny, got {:?}", d),
3619 }
3620
3621 let mut bag = AttributeBag::new();
3623 bag.set("authenticated", true);
3624 bag.set("delegation.depth", 1_i64);
3625 match crate::evaluate_effects(
3626 &route.policy,
3627 &mut bag,
3628 &pdp,
3629 &plugins,
3630 &delegations,
3631 crate::DispatchPhase::Pre,
3632 &mut crate::route::RoutePayload::new(serde_json::Value::Null),
3633 )
3634 .await
3635 .decision
3636 {
3637 Decision::Deny { rule_source, .. } => {
3638 assert!(
3639 rule_source.contains("policy[1]"),
3640 "expected policy[1], got {}",
3641 rule_source
3642 );
3643 },
3644 d => panic!("expected Deny, got {:?}", d),
3645 }
3646 }
3647
3648 struct NullPdpResolver;
3651 #[async_trait::async_trait]
3652 impl crate::PdpResolver for NullPdpResolver {
3653 fn dialect(&self) -> crate::PdpDialect {
3654 crate::PdpDialect::Cedar
3655 }
3656 async fn evaluate(
3657 &self,
3658 _call: &crate::PdpCall,
3659 _bag: &crate::AttributeBag,
3660 ) -> Result<crate::PdpDecision, crate::PdpError> {
3661 panic!("NullPdpResolver should not be invoked in pure-rule tests");
3662 }
3663 }
3664
3665 struct NullPluginInvoker;
3666 #[async_trait::async_trait]
3667 impl crate::PluginInvoker for NullPluginInvoker {
3668 async fn invoke(
3669 &self,
3670 _name: &str,
3671 _bag: &crate::AttributeBag,
3672 _invocation: crate::PluginInvocation<'_>,
3673 ) -> Result<crate::PluginOutcome, crate::PluginError> {
3674 panic!("NullPluginInvoker should not be invoked in pure-rule tests");
3675 }
3676 }
3677
3678 #[test]
3681 fn pipeline_simple_bare_stages() {
3682 let p = parse_pipeline("str").unwrap();
3683 assert_eq!(p.stages, vec![Stage::Type(TypeCheck::Str)]);
3684
3685 let p = parse_pipeline("omit").unwrap();
3686 assert_eq!(p.stages, vec![Stage::Omit]);
3687
3688 let p = parse_pipeline("hash").unwrap();
3689 assert_eq!(p.stages, vec![Stage::Hash]);
3690 }
3691
3692 #[test]
3693 fn pipeline_chains_split_on_pipe() {
3694 let p = parse_pipeline("str | mask(4)").unwrap();
3695 assert_eq!(
3696 p.stages,
3697 vec![Stage::Type(TypeCheck::Str), Stage::Mask { keep_last: 4 },]
3698 );
3699
3700 let p = parse_pipeline("int | 0..1M").unwrap();
3701 assert_eq!(
3702 p.stages,
3703 vec![
3704 Stage::Type(TypeCheck::Int),
3705 Stage::Range {
3706 min: Some(0),
3707 max: Some(1_000_000)
3708 },
3709 ]
3710 );
3711 }
3712
3713 #[test]
3714 fn pipeline_pipe_inside_parens_does_not_split() {
3715 let p = parse_pipeline("str | redact(!perm.view_ssn | role.admin)").unwrap();
3718 assert_eq!(p.stages.len(), 2);
3719 match &p.stages[1] {
3720 Stage::Redact { condition: Some(_) } => {},
3721 other => panic!("expected Redact with condition, got {:?}", other),
3722 }
3723 }
3724
3725 #[test]
3726 fn pipeline_length_constraints() {
3727 let p = parse_pipeline("len(..500)").unwrap();
3728 assert_eq!(
3729 p.stages,
3730 vec![Stage::Length {
3731 min: None,
3732 max: Some(500)
3733 }]
3734 );
3735 let p = parse_pipeline("len(10..50)").unwrap();
3736 assert_eq!(
3737 p.stages,
3738 vec![Stage::Length {
3739 min: Some(10),
3740 max: Some(50)
3741 }]
3742 );
3743 let p = parse_pipeline("len(8..)").unwrap();
3744 assert_eq!(
3745 p.stages,
3746 vec![Stage::Length {
3747 min: Some(8),
3748 max: None
3749 }]
3750 );
3751 }
3752
3753 #[test]
3754 fn pipeline_range_with_suffixes() {
3755 let p = parse_pipeline("0..10k").unwrap();
3756 assert_eq!(
3757 p.stages,
3758 vec![Stage::Range {
3759 min: Some(0),
3760 max: Some(10_000)
3761 }]
3762 );
3763 let p = parse_pipeline("0..1M").unwrap();
3764 assert_eq!(
3765 p.stages,
3766 vec![Stage::Range {
3767 min: Some(0),
3768 max: Some(1_000_000)
3769 }]
3770 );
3771 let p = parse_pipeline("..500").unwrap();
3772 assert_eq!(
3773 p.stages,
3774 vec![Stage::Range {
3775 min: None,
3776 max: Some(500)
3777 }]
3778 );
3779 }
3780
3781 #[test]
3782 fn pipeline_enum_unquoted_and_quoted() {
3783 let p = parse_pipeline("enum(low, medium, high)").unwrap();
3784 assert_eq!(
3785 p.stages,
3786 vec![Stage::Enum {
3787 values: vec!["low".into(), "medium".into(), "high".into()],
3788 }]
3789 );
3790 let p = parse_pipeline(r#"enum("a", "b")"#).unwrap();
3791 assert_eq!(
3792 p.stages,
3793 vec![Stage::Enum {
3794 values: vec!["a".into(), "b".into()],
3795 }]
3796 );
3797 }
3798
3799 #[test]
3800 fn pipeline_redact_with_predicate_condition() {
3801 let p = parse_pipeline("str | redact(!perm.view_ssn)").unwrap();
3802 assert_eq!(p.stages.len(), 2);
3803 match &p.stages[1] {
3804 Stage::Redact {
3805 condition: Some(Expression::Not(inner)),
3806 } => match inner.as_ref() {
3807 Expression::Condition(Condition::IsTrue { key }) => {
3808 assert_eq!(key, "perm.view_ssn");
3809 },
3810 other => panic!("expected IsTrue(perm.view_ssn), got {:?}", other),
3811 },
3812 other => panic!("expected Redact with Not condition, got {:?}", other),
3813 }
3814 }
3815
3816 #[test]
3817 fn pipeline_taint_scopes() {
3818 let p = parse_pipeline("taint(PII)").unwrap();
3819 assert_eq!(
3820 p.stages,
3821 vec![Stage::Taint {
3822 label: "PII".into(),
3823 scopes: vec![TaintScope::Session],
3824 }]
3825 );
3826 let p = parse_pipeline("taint(PII, message)").unwrap();
3827 assert_eq!(
3828 p.stages,
3829 vec![Stage::Taint {
3830 label: "PII".into(),
3831 scopes: vec![TaintScope::Message],
3832 }]
3833 );
3834 let p = parse_pipeline("taint(PII, [session, message])").unwrap();
3835 assert_eq!(
3836 p.stages,
3837 vec![Stage::Taint {
3838 label: "PII".into(),
3839 scopes: vec![TaintScope::Session, TaintScope::Message],
3840 }]
3841 );
3842 }
3843
3844 #[test]
3845 fn pipeline_unknown_stage_rejected() {
3846 let err = parse_pipeline("nonsense").unwrap_err();
3847 assert!(format!("{}", err).contains("unknown stage"));
3848 }
3849
3850 #[test]
3851 fn pipeline_omit_with_args_rejected() {
3852 let err = parse_pipeline("omit(!perm.x)").unwrap_err();
3854 assert!(format!("{}", err).contains("omit takes no arguments"));
3855 }
3856
3857 #[test]
3860 fn compile_route_with_args_and_result() {
3861 let yaml = r#"
3862routes:
3863 get_compensation:
3864 args:
3865 employee_id: "uuid"
3866 amount: "int | 0..1M"
3867 result:
3868 ssn: "str | redact(!perm.view_ssn)"
3869 employee_id: "str | mask(4)"
3870 internal_notes: "omit"
3871"#;
3872 let routes = compile_config(yaml).unwrap().routes;
3873 let route = routes.get("get_compensation").expect("missing route");
3874 assert_eq!(route.args.len(), 2);
3875 assert_eq!(route.result.len(), 3);
3876
3877 let ssn = route.result.iter().find(|f| f.field == "ssn").unwrap();
3879 assert_eq!(ssn.pipeline.stages.len(), 2);
3880 assert!(matches!(
3881 ssn.pipeline.stages[0],
3882 Stage::Type(TypeCheck::Str)
3883 ));
3884 assert!(matches!(
3885 ssn.pipeline.stages[1],
3886 Stage::Redact { condition: Some(_) }
3887 ));
3888
3889 let phases = route.declared_phases();
3891 assert!(phases.contains(crate::rules::Phase::Args));
3892 assert!(phases.contains(crate::rules::Phase::Result));
3893 }
3894
3895 #[test]
3896 fn compile_route_with_only_args_still_compiles() {
3897 let yaml = r#"
3900routes:
3901 validate_only:
3902 args:
3903 employee_id: "uuid"
3904"#;
3905 let routes = compile_config(yaml).unwrap().routes;
3906 assert!(routes.contains_key("validate_only"));
3907 }
3908
3909 #[test]
3910 fn compile_propagates_pipeline_parse_errors() {
3911 let yaml = r#"
3912routes:
3913 bad:
3914 result:
3915 x: "nonsense"
3916"#;
3917 let err = compile_config(yaml).unwrap_err();
3918 assert!(format!("{}", err).contains("unknown stage"));
3919 }
3920
3921 #[test]
3924 fn compile_captures_root_plugins_block_into_registry() {
3925 let yaml = r#"
3926plugins:
3927 - name: rate_limiter
3928 kind: native
3929 hooks: [tool_pre_invoke]
3930 capabilities: [read_subject]
3931 config:
3932 max_requests: 100
3933 - name: audit
3934 kind: native
3935 hooks: [tool_post_invoke]
3936routes:
3937 get_compensation:
3938 policy:
3939 - "plugin(rate_limiter)"
3940"#;
3941 let cfg = compile_config(yaml).unwrap();
3942 assert_eq!(cfg.plugins.len(), 2);
3943 let rl = cfg.plugins.get("rate_limiter").unwrap();
3944 assert_eq!(rl.kind, "native");
3945 assert_eq!(rl.hooks, vec!["tool_pre_invoke".to_string()]);
3946 assert_eq!(rl.capabilities, vec!["read_subject".to_string()]);
3947 assert!(cfg.routes.contains_key("get_compensation"));
3949 }
3950
3951 #[test]
3952 fn compile_captures_route_level_plugin_overrides() {
3953 let yaml = r#"
3954plugins:
3955 - name: rate_limiter
3956 kind: native
3957 hooks: [tool_pre_invoke]
3958 config:
3959 max_requests: 100
3960routes:
3961 hot_path:
3962 policy:
3963 - "plugin(rate_limiter)"
3964 plugins:
3965 rate_limiter:
3966 config:
3967 max_requests: 10
3968 on_error: ignore
3969"#;
3970 let cfg = compile_config(yaml).unwrap();
3971 let route = cfg.routes.get("hot_path").unwrap();
3972 let ovr = route.plugin_overrides.get("rate_limiter").unwrap();
3973 assert_eq!(ovr.on_error.as_deref(), Some("ignore"));
3974 let cfg_yaml = ovr.config.as_ref().unwrap();
3975 assert_eq!(
3976 cfg_yaml["max_requests"],
3977 serde_yaml::from_str::<serde_yaml::Value>("10").unwrap()
3978 );
3979
3980 let eff = crate::plugin_decl::EffectivePlugin::resolve(
3982 "rate_limiter",
3983 &cfg.plugins,
3984 &route.plugin_overrides,
3985 )
3986 .unwrap();
3987 assert_eq!(eff.on_error, Some("ignore"));
3988 assert_eq!(eff.hooks, &["tool_pre_invoke".to_string()]);
3990 }
3991
3992 #[test]
3995 fn compile_policy_block_value_parses_apl_body() {
3996 let yaml = r#"
3997policy:
3998 - "require(authenticated)"
3999result:
4000 ssn: "redact(!perm.view_ssn)"
4001"#;
4002 let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
4003 let compiled =
4004 compile_policy_block_value("global.policy.all", &value).expect("compile block");
4005 assert_eq!(compiled.route_key, "global.policy.all");
4006 assert_eq!(compiled.policy.len(), 1);
4007 assert_eq!(compiled.result.len(), 1);
4008 assert_eq!(compiled.result[0].field, "ssn");
4009 }
4010
4011 #[test]
4012 fn compile_policy_block_value_null_is_empty_route() {
4013 let value = serde_yaml::Value::Null;
4014 let compiled =
4015 compile_policy_block_value("global.defaults.tool", &value).expect("compile null");
4016 assert!(compiled.declared_phases().is_empty());
4017 assert_eq!(compiled.route_key, "global.defaults.tool");
4018 }
4019
4020 #[test]
4021 fn compile_policy_block_value_threads_source_into_rule_paths() {
4022 let yaml = r#"
4023policy:
4024 - "require(authenticated)"
4025"#;
4026 let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
4027 let compiled = compile_policy_block_value("global.policies.hr", &value).expect("compile");
4028 match &compiled.policy[0] {
4029 crate::rules::Effect::When { source, .. } => {
4030 assert_eq!(source, "global.policies.hr.policy[0]");
4031 },
4032 other => panic!("expected When, got {:?}", other),
4033 }
4034 }
4035
4036 #[test]
4039 fn parse_delegate_step_with_only_plugin() {
4040 let yaml = r#"
4041- delegate:
4042 plugin: workday-oauth
4043"#;
4044 let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
4045 let entry = &value.as_sequence().unwrap()[0];
4046 let step = parse_step(entry, "test.policy[0]").expect("parse");
4047 let crate::step::Step::Delegate(ds) = step else {
4048 panic!("expected Delegate, got {step:?}");
4049 };
4050 assert_eq!(ds.plugin_name, "workday-oauth");
4051 assert!(ds.config_override.is_none());
4052 assert!(ds.on_error.is_none());
4053 assert_eq!(ds.source, "test.policy[0]");
4054 }
4055
4056 #[test]
4057 fn parse_delegate_step_with_config_and_on_error() {
4058 let yaml = r#"
4059- delegate:
4060 plugin: workday-oauth
4061 config:
4062 target: workday-api
4063 permissions: [read_compensation]
4064 on_error: deny
4065"#;
4066 let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
4067 let entry = &value.as_sequence().unwrap()[0];
4068 let step = parse_step(entry, "test.policy[1]").expect("parse");
4069 let crate::step::Step::Delegate(ds) = step else {
4070 panic!("expected Delegate, got {step:?}");
4071 };
4072 assert_eq!(ds.plugin_name, "workday-oauth");
4073 assert_eq!(ds.on_error.as_deref(), Some("deny"));
4074 let cfg = ds.config_override.as_ref().expect("config_override set");
4075 let target = cfg
4076 .as_mapping()
4077 .and_then(|m| m.get(serde_yaml::Value::String("target".into())))
4078 .and_then(|v| v.as_str());
4079 assert_eq!(target, Some("workday-api"));
4080 }
4081
4082 #[test]
4083 fn parse_delegate_step_missing_plugin_errors() {
4084 let yaml = r#"
4085- delegate:
4086 config: { target: workday-api }
4087"#;
4088 let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
4089 let entry = &value.as_sequence().unwrap()[0];
4090 let err = parse_step(entry, "test.policy[0]").expect_err("missing plugin");
4091 let msg = format!("{err}");
4092 assert!(msg.contains("requires `plugin:"), "got: {msg}");
4093 }
4094
4095 #[test]
4096 fn parse_delegate_step_empty_plugin_errors() {
4097 let yaml = r#"
4098- delegate:
4099 plugin: ""
4100"#;
4101 let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
4102 let entry = &value.as_sequence().unwrap()[0];
4103 let err = parse_step(entry, "test.policy[0]").expect_err("empty plugin");
4104 let msg = format!("{err}");
4105 assert!(msg.contains("cannot be empty"), "got: {msg}");
4106 }
4107
4108 #[test]
4109 fn parse_delegate_step_non_string_on_error_errors() {
4110 let yaml = r#"
4111- delegate:
4112 plugin: workday-oauth
4113 on_error: 42
4114"#;
4115 let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
4116 let entry = &value.as_sequence().unwrap()[0];
4117 let err = parse_step(entry, "test.policy[0]").expect_err("non-string on_error");
4118 let msg = format!("{err}");
4119 assert!(msg.contains("on_error"), "got: {msg}");
4120 }
4121
4122 #[test]
4123 fn parse_delegate_step_non_map_body_errors() {
4124 let yaml = r#"
4125- delegate: workday-oauth
4126"#;
4127 let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
4128 let entry = &value.as_sequence().unwrap()[0];
4129 let err = parse_step(entry, "test.policy[0]").expect_err("non-map delegate body");
4130 let msg = format!("{err}");
4131 assert!(msg.contains("must be a map"), "got: {msg}");
4132 }
4133
4134 #[test]
4137 fn parse_delegate_string_bare_plugin_name() {
4138 let yaml = r#"- "delegate(workday-oauth)""#;
4139 let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
4140 let entry = &value.as_sequence().unwrap()[0];
4141 let step = parse_step(entry, "test.policy[0]").expect("parse");
4142 let crate::step::Step::Delegate(ds) = step else {
4143 panic!("expected Delegate, got {step:?}");
4144 };
4145 assert_eq!(ds.plugin_name, "workday-oauth");
4146 assert!(ds.config_override.is_none());
4147 assert!(ds.on_error.is_none());
4148 assert_eq!(ds.source, "test.policy[0]");
4149 }
4150
4151 #[test]
4152 fn parse_delegate_string_with_string_kwargs() {
4153 let yaml =
4154 r#"- "delegate(workday-oauth, target: workday-api, audience: https://workday.com)""#;
4155 let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
4156 let entry = &value.as_sequence().unwrap()[0];
4157 let step = parse_step(entry, "test.policy[0]").expect("parse");
4158 let crate::step::Step::Delegate(ds) = step else {
4159 panic!("expected Delegate, got {step:?}");
4160 };
4161 assert_eq!(ds.plugin_name, "workday-oauth");
4162 let cfg = ds.config_override.as_ref().unwrap().as_mapping().unwrap();
4163 assert_eq!(
4164 cfg.get(serde_yaml::Value::String("target".into()))
4165 .and_then(|v| v.as_str()),
4166 Some("workday-api"),
4167 );
4168 assert_eq!(
4169 cfg.get(serde_yaml::Value::String("audience".into()))
4170 .and_then(|v| v.as_str()),
4171 Some("https://workday.com"),
4172 );
4173 }
4174
4175 #[test]
4176 fn parse_delegate_string_with_list_kwarg() {
4177 let yaml = r#"- "delegate(workday-oauth, permissions: [read_compensation, write_notes])""#;
4178 let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
4179 let entry = &value.as_sequence().unwrap()[0];
4180 let step = parse_step(entry, "test.policy[0]").expect("parse");
4181 let crate::step::Step::Delegate(ds) = step else {
4182 panic!("expected Delegate");
4183 };
4184 let cfg = ds.config_override.as_ref().unwrap().as_mapping().unwrap();
4185 let perms = cfg
4186 .get(serde_yaml::Value::String("permissions".into()))
4187 .and_then(|v| v.as_sequence())
4188 .expect("permissions sequence");
4189 let names: Vec<&str> = perms.iter().filter_map(|v| v.as_str()).collect();
4190 assert_eq!(names, vec!["read_compensation", "write_notes"]);
4191 }
4192
4193 #[test]
4194 fn parse_delegate_string_on_error_pulled_out() {
4195 let yaml = r#"- "delegate(workday-oauth, target: workday-api, on_error: continue)""#;
4196 let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
4197 let entry = &value.as_sequence().unwrap()[0];
4198 let step = parse_step(entry, "test.policy[0]").expect("parse");
4199 let crate::step::Step::Delegate(ds) = step else {
4200 panic!("expected Delegate");
4201 };
4202 assert_eq!(ds.on_error.as_deref(), Some("continue"));
4203 let cfg = ds.config_override.as_ref().unwrap().as_mapping().unwrap();
4205 assert!(
4206 cfg.get(serde_yaml::Value::String("on_error".into()))
4207 .is_none(),
4208 "on_error must not appear in config_override"
4209 );
4210 }
4211
4212 #[test]
4213 fn parse_delegate_string_quoted_plugin_name() {
4214 let yaml = r#"- 'delegate("workday-oauth")'"#;
4218 let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
4219 let entry = &value.as_sequence().unwrap()[0];
4220 let step = parse_step(entry, "test.policy[0]").expect("parse");
4221 let crate::step::Step::Delegate(ds) = step else {
4222 panic!("expected Delegate");
4223 };
4224 assert_eq!(ds.plugin_name, "workday-oauth");
4225 }
4226
4227 #[test]
4228 fn parse_delegate_string_quoted_value_preserves_internal_commas() {
4229 let yaml =
4230 r#"- 'delegate(workday-oauth, audience: "https://workday.com,backup.workday.com")'"#;
4231 let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
4232 let entry = &value.as_sequence().unwrap()[0];
4233 let step = parse_step(entry, "test.policy[0]").expect("parse");
4234 let crate::step::Step::Delegate(ds) = step else {
4235 panic!("expected Delegate");
4236 };
4237 let cfg = ds.config_override.as_ref().unwrap().as_mapping().unwrap();
4238 assert_eq!(
4239 cfg.get(serde_yaml::Value::String("audience".into()))
4240 .and_then(|v| v.as_str()),
4241 Some("https://workday.com,backup.workday.com"),
4242 );
4243 }
4244
4245 #[test]
4246 fn parse_delegate_string_empty_args_errors() {
4247 let yaml = r#"- "delegate()""#;
4248 let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
4249 let entry = &value.as_sequence().unwrap()[0];
4250 let err = parse_step(entry, "test.policy[0]").expect_err("empty args");
4251 let msg = format!("{err}");
4252 assert!(msg.contains("plugin name"), "got: {msg}");
4253 }
4254
4255 #[test]
4256 fn parse_delegate_string_plugin_kwarg_rejected() {
4257 let yaml = r#"- "delegate(workday-oauth, plugin: other-thing)""#;
4260 let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
4261 let entry = &value.as_sequence().unwrap()[0];
4262 let err = parse_step(entry, "test.policy[0]").expect_err("plugin kwarg");
4263 let msg = format!("{err}");
4264 assert!(msg.contains("positional"), "got: {msg}");
4265 }
4266
4267 #[test]
4268 fn parse_delegate_string_kwarg_missing_colon_errors() {
4269 let yaml = r#"- "delegate(workday-oauth, target workday-api)""#;
4270 let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
4271 let entry = &value.as_sequence().unwrap()[0];
4272 let err = parse_step(entry, "test.policy[0]").expect_err("missing colon");
4273 let msg = format!("{err}");
4274 assert!(msg.contains("key: value"), "got: {msg}");
4275 }
4276
4277 #[test]
4278 fn parse_delegate_string_unbalanced_brackets_errors() {
4279 let yaml = r#"- "delegate(workday-oauth, permissions: [read_compensation)""#;
4280 let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
4281 let entry = &value.as_sequence().unwrap()[0];
4282 let err = parse_step(entry, "test.policy[0]").expect_err("unbalanced");
4283 let msg = format!("{err}");
4284 assert!(
4285 msg.contains("unmatched") || msg.contains("unbalanced"),
4286 "got: {msg}"
4287 );
4288 }
4289
4290 #[test]
4291 fn compile_route_mixed_string_and_map_delegate_forms() {
4292 let yaml = r#"
4295routes:
4296 get_compensation:
4297 policy:
4298 - "require(role.hr)"
4299 - "delegate(workday-oauth, target: workday-api, permissions: [read_compensation])"
4300 - delegate:
4301 plugin: audit-receipt
4302 on_error: continue
4303 config:
4304 mode: trace
4305"#;
4306 let cfg = compile_config(yaml).expect("compile");
4307 let route = cfg.routes.get("get_compensation").expect("route");
4308 assert_eq!(route.policy.len(), 3);
4309
4310 let crate::rules::Effect::Delegate(s1) = &route.policy[1] else {
4312 panic!("expected Delegate at policy[1]");
4313 };
4314 assert_eq!(s1.plugin_name, "workday-oauth");
4315 assert!(s1.on_error.is_none());
4316
4317 let crate::rules::Effect::Delegate(s2) = &route.policy[2] else {
4319 panic!("expected Delegate at policy[2]");
4320 };
4321 assert_eq!(s2.plugin_name, "audit-receipt");
4322 assert_eq!(s2.on_error.as_deref(), Some("continue"));
4323 }
4324
4325 #[test]
4326 fn compile_route_with_delegate_in_policy_and_post_policy() {
4327 let yaml = r#"
4331routes:
4332 get_compensation:
4333 policy:
4334 - "require(role.hr)"
4335 - delegate:
4336 plugin: workday-oauth
4337 config:
4338 target: workday-api
4339 permissions: [read_compensation]
4340 - "require(authenticated)"
4341 post_policy:
4342 - delegate:
4343 plugin: audit-biscuit
4344 on_error: continue
4345"#;
4346 let cfg = compile_config(yaml).expect("compile");
4347 let route = cfg.routes.get("get_compensation").expect("route present");
4348 assert_eq!(route.policy.len(), 3);
4349
4350 let crate::rules::Effect::Delegate(ds) = &route.policy[1] else {
4352 panic!("expected Delegate at policy[1], got {:?}", route.policy[1]);
4353 };
4354 assert_eq!(ds.plugin_name, "workday-oauth");
4355 assert_eq!(ds.source, "get_compensation.policy[1]");
4356
4357 let crate::rules::Effect::Delegate(post_ds) = &route.post_policy[0] else {
4359 panic!("expected Delegate at post_policy[0]");
4360 };
4361 assert_eq!(post_ds.plugin_name, "audit-biscuit");
4362 assert_eq!(post_ds.on_error.as_deref(), Some("continue"));
4363 assert_eq!(post_ds.source, "get_compensation.post_policy[0]");
4364 }
4365
4366 #[test]
4369 fn parse_pipeline_rejects_validate_stage_at_compile_time() {
4370 let err = parse_pipeline("str | validate(ssn_format) | mask(4)")
4375 .expect_err("validate(name) should fail to parse");
4376 let msg = format!("{err}");
4377 assert!(
4378 msg.contains("not implemented"),
4379 "diagnostic should explain that validate is unimplemented: {msg}",
4380 );
4381 assert!(
4382 msg.contains("regex") && msg.contains("plugin"),
4383 "diagnostic should suggest regex(...) and plugin(...): {msg}",
4384 );
4385 assert!(
4386 msg.contains("ssn_format"),
4387 "diagnostic should echo the rejected validator name: {msg}",
4388 );
4389 }
4390
4391 #[test]
4392 fn parse_pipeline_does_not_reject_other_stages() {
4393 let p = parse_pipeline("str | len(..100) | regex(\"^[A-Z]+$\") | mask(4)")
4396 .expect("non-validate pipeline parses");
4397 assert_eq!(p.stages.len(), 4);
4398 }
4399}