1use std::collections::HashMap;
26
27use serde::Deserialize;
28use thiserror::Error;
29
30use crate::pipeline::{FieldRule, Pipeline, ScanKind, Stage, TaintScope, TypeCheck};
31use crate::plugin_decl::{PluginDeclaration, PluginOverride, PluginRegistry};
32use crate::rules::{CompareOp, CompiledRoute, Condition, Effect, Expression, Literal, Rule};
33use crate::step::{DelegateStep, PdpCall, PdpDialect, Step};
34
35#[derive(Debug, Error)]
40pub enum ParseError {
41 #[error("YAML parse error: {0}")]
42 Yaml(#[from] serde_yaml::Error),
43
44 #[error("rule '{rule}': {msg}")]
45 Rule { rule: String, msg: String },
46
47 #[error("unsupported step `{kind}` in rule '{rule}' — defer to step 5b")]
48 UnsupportedStep { rule: String, kind: String },
49
50 #[error("predicate '{predicate}': {msg}")]
51 Predicate { predicate: String, msg: String },
52
53 #[error("in `{location}`: config field `{old}` was renamed to `{new}` — update your config")]
54 RenamedField {
55 location: String,
56 old: String,
57 new: String,
58 },
59
60 #[error(
61 "in `{location}`: `{phase}` is declared both nested under `authorization:` and flat on \
62 the section — use one form, not both (declaring both runs the effects twice)"
63 )]
64 ConflictingAuthorizationForms { location: String, phase: String },
65}
66
67#[derive(Debug, Clone, PartialEq)]
72enum Tok {
73 Ident(String), StringLit(String),
75 IntLit(i64),
76 FloatLit(f64),
77 BoolLit(bool),
78 Eq, NotEq, Gt, GtEq, Lt, LtEq, And, Or, Not, LParen,
88 RParen,
89 Comma,
90 Contains, Require, Exists, In, }
95
96struct Lexer<'a> {
97 src: &'a str,
98 bytes: &'a [u8],
99 pos: usize,
100}
101
102impl<'a> Lexer<'a> {
103 fn new(src: &'a str) -> Self {
104 Self {
105 src,
106 bytes: src.as_bytes(),
107 pos: 0,
108 }
109 }
110
111 fn peek(&self) -> Option<u8> {
112 self.bytes.get(self.pos).copied()
113 }
114
115 fn bump(&mut self) -> Option<u8> {
116 let b = self.peek()?;
117 self.pos += 1;
118 Some(b)
119 }
120
121 fn skip_ws(&mut self) {
122 while let Some(b) = self.peek() {
123 if b.is_ascii_whitespace() {
124 self.pos += 1;
125 } else {
126 break;
127 }
128 }
129 }
130
131 fn tokenize_all(&mut self) -> Result<Vec<Tok>, ParseError> {
132 let mut out = Vec::new();
133 loop {
134 self.skip_ws();
135 let Some(b) = self.peek() else {
136 return Ok(out);
137 };
138
139 let tok = match b {
140 b'(' => {
141 self.pos += 1;
142 Tok::LParen
143 },
144 b')' => {
145 self.pos += 1;
146 Tok::RParen
147 },
148 b',' => {
149 self.pos += 1;
150 Tok::Comma
151 },
152 b'&' => {
153 self.pos += 1;
154 Tok::And
155 },
156 b'|' => {
157 self.pos += 1;
158 Tok::Or
159 },
160 b'=' => {
161 self.pos += 1;
162 if self.peek() == Some(b'=') {
163 self.pos += 1;
164 Tok::Eq
165 } else {
166 return Err(self.err("expected `==`, saw `=`"));
167 }
168 },
169 b'!' => {
170 self.pos += 1;
171 if self.peek() == Some(b'=') {
172 self.pos += 1;
173 Tok::NotEq
174 } else {
175 Tok::Not
176 }
177 },
178 b'>' => {
179 self.pos += 1;
180 if self.peek() == Some(b'=') {
181 self.pos += 1;
182 Tok::GtEq
183 } else {
184 Tok::Gt
185 }
186 },
187 b'<' => {
188 self.pos += 1;
189 if self.peek() == Some(b'=') {
190 self.pos += 1;
191 Tok::LtEq
192 } else {
193 Tok::Lt
194 }
195 },
196 b'"' | b'\'' => self.lex_string(b)?,
197 b'-' | b'0'..=b'9' => self.lex_number()?,
198 b if is_ident_start(b) => self.lex_ident_or_keyword(),
199 _ => return Err(self.err(&format!("unexpected char `{}`", b as char))),
200 };
201 out.push(tok);
202 }
203 }
204
205 fn lex_string(&mut self, quote: u8) -> Result<Tok, ParseError> {
206 self.bump(); let start = self.pos;
208 while let Some(b) = self.peek() {
209 if b == quote {
210 break;
211 }
212 self.pos += 1;
213 }
214 if self.peek() != Some(quote) {
215 return Err(self.err("unterminated string literal"));
216 }
217 let s = std::str::from_utf8(&self.bytes[start..self.pos])
218 .map_err(|_| self.err("non-utf8 in string literal"))?
219 .to_string();
220 self.bump(); Ok(Tok::StringLit(s))
222 }
223
224 fn lex_number(&mut self) -> Result<Tok, ParseError> {
225 let start = self.pos;
226 if self.peek() == Some(b'-') {
227 self.pos += 1;
228 }
229 while let Some(b) = self.peek() {
230 if b.is_ascii_digit() {
231 self.pos += 1;
232 } else {
233 break;
234 }
235 }
236 let mut is_float = false;
237 if self.peek() == Some(b'.') {
238 is_float = true;
239 self.pos += 1;
240 while let Some(b) = self.peek() {
241 if b.is_ascii_digit() {
242 self.pos += 1;
243 } else {
244 break;
245 }
246 }
247 }
248 let text = &self.src[start..self.pos];
249 if is_float {
250 text.parse::<f64>()
251 .map(Tok::FloatLit)
252 .map_err(|_| self.err(&format!("bad float `{}`", text)))
253 } else {
254 text.parse::<i64>()
255 .map(Tok::IntLit)
256 .map_err(|_| self.err(&format!("bad int `{}`", text)))
257 }
258 }
259
260 fn lex_ident_or_keyword(&mut self) -> Tok {
261 let start = self.pos;
262 while let Some(b) = self.peek() {
263 if is_ident_cont(b) {
264 self.pos += 1;
265 } else {
266 break;
267 }
268 }
269 let s = &self.src[start..self.pos];
270 match s {
271 "true" => Tok::BoolLit(true),
272 "false" => Tok::BoolLit(false),
273 "contains" => Tok::Contains,
274 "require" => Tok::Require,
275 "exists" => Tok::Exists,
276 "in" => Tok::In,
277 _ => Tok::Ident(s.to_string()),
281 }
282 }
283
284 fn err(&self, msg: &str) -> ParseError {
285 ParseError::Predicate {
286 predicate: self.src.to_string(),
287 msg: format!("at byte {}: {}", self.pos, msg),
288 }
289 }
290}
291
292fn is_ident_start(b: u8) -> bool {
293 b.is_ascii_alphabetic() || b == b'_'
294}
295
296fn is_ident_cont(b: u8) -> bool {
297 b.is_ascii_alphanumeric() || b == b'_' || b == b'.'
298}
299
300struct PredParser<'a> {
305 src: &'a str,
306 toks: Vec<Tok>,
307 pos: usize,
308}
309
310impl<'a> PredParser<'a> {
311 fn parse(src: &'a str) -> Result<Expression, ParseError> {
312 let toks = Lexer::new(src).tokenize_all()?;
313 let mut p = Self { src, toks, pos: 0 };
314 let expr = p.parse_or()?;
315 if p.pos < p.toks.len() {
316 return Err(p.err(&format!(
317 "trailing tokens after expression: {:?}",
318 &p.toks[p.pos..]
319 )));
320 }
321 Ok(expr)
322 }
323
324 fn peek(&self) -> Option<&Tok> {
325 self.toks.get(self.pos)
326 }
327 fn bump(&mut self) -> Option<Tok> {
328 let t = self.toks.get(self.pos).cloned()?;
329 self.pos += 1;
330 Some(t)
331 }
332 fn err(&self, msg: &str) -> ParseError {
333 ParseError::Predicate {
334 predicate: self.src.to_string(),
335 msg: msg.to_string(),
336 }
337 }
338
339 fn parse_or(&mut self) -> Result<Expression, ParseError> {
340 let mut parts = vec![self.parse_and()?];
341 while matches!(self.peek(), Some(Tok::Or)) {
342 self.bump();
343 parts.push(self.parse_and()?);
344 }
345 Ok(if parts.len() == 1 {
346 parts.pop().unwrap()
347 } else {
348 Expression::Or(parts)
349 })
350 }
351
352 fn parse_and(&mut self) -> Result<Expression, ParseError> {
353 let mut parts = vec![self.parse_unary()?];
354 while matches!(self.peek(), Some(Tok::And)) {
355 self.bump();
356 parts.push(self.parse_unary()?);
357 }
358 Ok(if parts.len() == 1 {
359 parts.pop().unwrap()
360 } else {
361 Expression::And(parts)
362 })
363 }
364
365 fn parse_unary(&mut self) -> Result<Expression, ParseError> {
366 if matches!(self.peek(), Some(Tok::Not)) {
367 self.bump();
368 let inner = self.parse_unary()?;
369 return Ok(Expression::Not(Box::new(inner)));
370 }
371 self.parse_atom()
372 }
373
374 fn parse_atom(&mut self) -> Result<Expression, ParseError> {
375 match self.peek() {
376 Some(Tok::LParen) => {
377 self.bump();
378 let inner = self.parse_or()?;
379 match self.bump() {
380 Some(Tok::RParen) => Ok(inner),
381 _ => Err(self.err("expected `)`")),
382 }
383 },
384 Some(Tok::Require) => Err(self.err(
388 "`require(...)` is a rule-level shorthand, not a sub-predicate \
389 — use `&` / `|` / `!` over bare identifiers instead",
390 )),
391 Some(Tok::Exists) => self.parse_exists(),
392 Some(Tok::Ident(_)) => self.parse_identifier_predicate(),
393 other => Err(self.err(&format!("expected atom, got {:?}", other))),
394 }
395 }
396
397 fn parse_exists(&mut self) -> Result<Expression, ParseError> {
400 self.bump(); match self.bump() {
402 Some(Tok::LParen) => {},
403 _ => return Err(self.err("expected `(` after `exists`")),
404 }
405 let key = match self.bump() {
406 Some(Tok::Ident(s)) => s,
407 other => {
408 return Err(self.err(&format!(
409 "exists(...) expects an attribute key, got {:?}",
410 other,
411 )))
412 },
413 };
414 match self.bump() {
415 Some(Tok::RParen) => {},
416 other => {
417 return Err(self.err(&format!(
418 "expected `)` after exists() argument, got {:?}",
419 other,
420 )))
421 },
422 }
423 Ok(Expression::Condition(Condition::Exists { key }))
424 }
425
426 fn parse_identifier_predicate(&mut self) -> Result<Expression, ParseError> {
433 let key = match self.bump() {
434 Some(Tok::Ident(s)) => s,
435 _ => unreachable!("parse_atom dispatched here"),
436 };
437
438 if matches!(self.peek(), Some(Tok::In)) {
440 self.bump();
441 return self.finish_in_set(key, false);
442 }
443 if let Some(Tok::Ident(maybe_not)) = self.peek() {
447 if maybe_not == "not" {
448 let saved_pos = self.pos;
449 self.bump(); if matches!(self.peek(), Some(Tok::In)) {
451 self.bump();
452 return self.finish_in_set(key, true);
453 }
454 self.pos = saved_pos;
457 }
458 }
459
460 let op = match self.peek() {
461 Some(Tok::Eq) => Some(CompareOp::Eq),
462 Some(Tok::NotEq) => Some(CompareOp::NotEq),
463 Some(Tok::Gt) => Some(CompareOp::Gt),
464 Some(Tok::GtEq) => Some(CompareOp::GtEq),
465 Some(Tok::Lt) => Some(CompareOp::Lt),
466 Some(Tok::LtEq) => Some(CompareOp::LtEq),
467 Some(Tok::Contains) => Some(CompareOp::Contains),
468 _ => None,
469 };
470
471 let Some(op) = op else {
472 return Ok(Expression::Condition(Condition::IsTrue { key }));
474 };
475 self.bump();
476
477 let value = match self.bump() {
478 Some(Tok::StringLit(s)) => Literal::String(s),
479 Some(Tok::IntLit(i)) => Literal::Int(i),
480 Some(Tok::FloatLit(f)) => Literal::Float(f),
481 Some(Tok::BoolLit(b)) => Literal::Bool(b),
482 Some(Tok::Ident(_)) => {
483 return Err(self.err(
484 "RHS-as-identifier on comparison operators not supported — \
485 for set membership use `value_key in set_key`",
486 ));
487 },
488 other => return Err(self.err(&format!("expected literal RHS, got {:?}", other))),
489 };
490
491 Ok(Expression::Condition(Condition::Comparison {
492 key,
493 op,
494 value,
495 }))
496 }
497
498 fn finish_in_set(&mut self, value_key: String, negate: bool) -> Result<Expression, ParseError> {
499 let set_key = match self.bump() {
500 Some(Tok::Ident(s)) => s,
501 other => {
502 return Err(self.err(&format!(
503 "expected set-attribute identifier after `{}in`, got {:?}",
504 if negate { "not " } else { "" },
505 other,
506 )))
507 },
508 };
509 Ok(Expression::Condition(Condition::InSet {
510 value_key,
511 set_key,
512 negate,
513 }))
514 }
515}
516
517pub fn parse_predicate(src: &str) -> Result<Expression, ParseError> {
519 PredParser::parse(src.trim())
520}
521
522pub fn parse_rule(line: &str, source: &str) -> Result<Rule, ParseError> {
541 let trimmed = line.trim();
542
543 if is_require_call(trimmed) {
547 let condition = parse_require_rule(trimmed)?;
548 return Ok(Rule::single(
549 condition,
550 Effect::Deny {
551 reason: None,
552 code: None,
553 },
554 source,
555 ));
556 }
557
558 if let Some(kind) = detect_step_kind(trimmed) {
561 return Err(ParseError::UnsupportedStep {
562 rule: trimmed.to_string(),
563 kind: format!("{} (use parse_step for step kinds)", kind),
564 });
565 }
566
567 let (predicate_str, effects) = match split_predicate_action(trimmed) {
568 Some((p, a)) => (p, parse_action(a, trimmed)?),
569 None => {
570 if let Some(effects) = try_bare_action(trimmed) {
572 return Ok(Rule {
573 condition: Expression::Always,
574 effects,
575 source: source.to_string(),
576 });
577 }
578 if let Some(deny) = try_parse_deny_call(trimmed, trimmed)? {
585 return Ok(Rule {
586 condition: Expression::Always,
587 effects: vec![deny],
588 source: source.to_string(),
589 });
590 }
591 (
593 trimmed,
594 vec![Effect::Deny {
595 reason: None,
596 code: None,
597 }],
598 )
599 },
600 };
601
602 let condition = parse_predicate(predicate_str).map_err(|e| ParseError::Rule {
603 rule: trimmed.to_string(),
604 msg: format!("{}", e),
605 })?;
606
607 Ok(Rule {
608 condition,
609 effects,
610 source: source.to_string(),
611 })
612}
613
614fn is_require_call(s: &str) -> bool {
615 s.trim_start().starts_with("require(")
616}
617
618fn parse_require_rule(line: &str) -> Result<Expression, ParseError> {
627 let toks = Lexer::new(line).tokenize_all()?;
628 let mut iter = toks.into_iter().peekable();
629
630 let bad = |msg: &str| ParseError::Rule {
631 rule: line.to_string(),
632 msg: msg.to_string(),
633 };
634
635 match iter.next() {
636 Some(Tok::Require) => {},
637 _ => return Err(bad("expected `require`")),
638 }
639 match iter.next() {
640 Some(Tok::LParen) => {},
641 _ => return Err(bad("expected `(` after `require`")),
642 }
643
644 let mut keys = Vec::new();
645 let mut sep: Option<Tok> = None;
646
647 match iter.next() {
648 Some(Tok::Ident(s)) => keys.push(s),
649 _ => return Err(bad("expected identifier inside `require(...)`")),
650 }
651
652 loop {
653 match iter.next() {
654 Some(Tok::RParen) => break,
655 Some(t @ Tok::Comma) | Some(t @ Tok::Or) => {
656 match &sep {
657 None => sep = Some(t),
658 Some(prev) if std::mem::discriminant(prev) == std::mem::discriminant(&t) => {},
659 _ => {
660 return Err(bad(
661 "require(...) cannot mix `,` (AND) and `|` (OR) — use one or the other",
662 ))
663 },
664 }
665 match iter.next() {
666 Some(Tok::Ident(s)) => keys.push(s),
667 _ => return Err(bad("expected identifier after `,` or `|` in require(...)")),
668 }
669 },
670 Some(other) => {
671 return Err(bad(&format!(
672 "expected `,`, `|`, or `)` in require(...), got {:?}",
673 other,
674 )))
675 },
676 None => return Err(bad("unexpected end of require(...) — missing `)`")),
677 }
678 }
679
680 if iter.peek().is_some() {
681 return Err(bad(
682 "trailing tokens after `require(...)` — require is a complete rule",
683 ));
684 }
685
686 let falses: Vec<Expression> = keys
687 .into_iter()
688 .map(|k| Expression::Condition(Condition::IsFalse { key: k }))
689 .collect();
690 if falses.len() == 1 {
691 return Ok(falses.into_iter().next().unwrap());
692 }
693 Ok(match sep {
694 Some(Tok::Or) => Expression::And(falses), _ => Expression::Or(falses), })
697}
698
699fn detect_step_kind(s: &str) -> Option<&'static str> {
701 let s = s.trim_start();
702 for prefix in [
703 "taint(",
704 "plugin(",
705 "run(",
706 "cedar:",
707 "opa(",
708 "authzen(",
709 "nemo(",
710 "cel:",
711 "sequential:",
712 "parallel:",
713 ] {
714 if s.starts_with(prefix) {
715 return Some(prefix.trim_end_matches('(').trim_end_matches(':'));
716 }
717 }
718 None
719}
720
721fn split_predicate_action(s: &str) -> Option<(&str, &str)> {
726 let bytes = s.as_bytes();
727 let mut depth: i32 = 0;
728 let mut in_quote: Option<u8> = None;
729 let mut last_colon: Option<usize> = None;
730 for (i, &b) in bytes.iter().enumerate() {
731 match (in_quote, b) {
732 (Some(q), c) if c == q => in_quote = None,
733 (Some(_), _) => {},
734 (None, b'"') | (None, b'\'') => in_quote = Some(b),
735 (None, b'(') => depth += 1,
736 (None, b')') => depth -= 1,
737 (None, b':') if depth == 0 => last_colon = Some(i),
738 _ => {},
739 }
740 }
741 last_colon.map(|i| (s[..i].trim(), s[i + 1..].trim()))
742}
743
744fn parse_action(s: &str, rule: &str) -> Result<Vec<Effect>, ParseError> {
757 if let Some(effect) = try_bare_action(s) {
758 return Ok(effect);
759 }
760 if let Some(deny) = try_parse_deny_call(s.trim(), rule)? {
761 return Ok(vec![deny]);
762 }
763 Err(ParseError::Rule {
764 rule: rule.to_string(),
765 msg: format!(
766 "unsupported action `{}` — recognized: `deny`, `deny('reason')`, `deny('reason', 'code')`, `allow`",
767 s.trim()
768 ),
769 })
770}
771
772fn try_bare_action(s: &str) -> Option<Vec<Effect>> {
773 match s.trim() {
774 "deny" => Some(vec![Effect::Deny {
775 reason: None,
776 code: None,
777 }]),
778 "allow" => Some(vec![Effect::Allow]),
779 _ => None,
780 }
781}
782
783fn try_parse_deny_call(s: &str, rule: &str) -> Result<Option<Effect>, ParseError> {
787 if !s.starts_with("deny(") {
788 return Ok(None);
789 }
790 let inside = extract_call_args(s, "deny").ok_or_else(|| ParseError::Rule {
791 rule: rule.to_string(),
792 msg: "malformed `deny(...)`".into(),
793 })?;
794 let parts = split_top_level_commas(&inside).map_err(|e| ParseError::Rule {
797 rule: rule.to_string(),
798 msg: format!("deny(...): {}", e),
799 })?;
800 let mut iter = parts.into_iter();
801 let reason = match iter.next() {
802 Some(p) => Some(strip_string_literal(p.trim(), rule)?),
803 None => None,
804 };
805 let code = match iter.next() {
806 Some(p) => Some(strip_string_literal(p.trim(), rule)?),
807 None => None,
808 };
809 if iter.next().is_some() {
810 return Err(ParseError::Rule {
811 rule: rule.to_string(),
812 msg: "deny(...) takes at most two args: deny('reason', 'code')".into(),
813 });
814 }
815 Ok(Some(Effect::Deny { reason, code }))
816}
817
818fn strip_string_literal(s: &str, rule: &str) -> Result<String, ParseError> {
822 let s = s.trim();
823 if (s.starts_with('\'') && s.ends_with('\'') && s.len() >= 2)
824 || (s.starts_with('"') && s.ends_with('"') && s.len() >= 2)
825 {
826 Ok(s[1..s.len() - 1].to_string())
827 } else {
828 Err(ParseError::Rule {
829 rule: rule.to_string(),
830 msg: format!("expected a quoted string, got `{}`", s),
831 })
832 }
833}
834
835pub fn parse_step(value: &serde_yaml::Value, source: &str) -> Result<Step, ParseError> {
851 match value {
852 serde_yaml::Value::String(s) => parse_step_string(s, source),
853 serde_yaml::Value::Mapping(m) => parse_step_map(m, source),
854 other => Err(ParseError::Rule {
855 rule: format!("{:?}", other),
856 msg: "step must be a string or a single-key map".into(),
857 }),
858 }
859}
860
861fn parse_step_string(line: &str, source: &str) -> Result<Step, ParseError> {
862 let trimmed = line.trim();
863
864 if trimmed.starts_with("taint(") {
867 let inside = extract_call_args(trimmed, "taint").ok_or_else(|| ParseError::Rule {
868 rule: trimmed.to_string(),
869 msg: "malformed `taint(...)`".into(),
870 })?;
871 let taint_stage = parse_taint(&inside, trimmed)?;
872 if let Stage::Taint { label, scopes } = taint_stage {
874 return Ok(Step::Taint { label, scopes });
875 }
876 unreachable!("parse_taint always returns Stage::Taint");
877 }
878
879 let plugin_verb = if trimmed.starts_with("plugin(") {
882 Some("plugin")
883 } else if trimmed.starts_with("run(") {
884 Some("run")
885 } else {
886 None
887 };
888 if let Some(verb) = plugin_verb {
889 let inside = extract_call_args(trimmed, verb).ok_or_else(|| ParseError::Rule {
890 rule: trimmed.to_string(),
891 msg: format!("malformed `{verb}(...)`"),
892 })?;
893 let name = inside.trim();
894 if name.is_empty() {
895 return Err(ParseError::Rule {
896 rule: trimmed.to_string(),
897 msg: format!("`{verb}(...)`: plugin name must not be empty"),
898 });
899 }
900 return Ok(Step::Plugin {
901 name: name.to_string(),
902 });
903 }
904
905 if trimmed.starts_with("delegate(") {
912 let inside = extract_call_args(trimmed, "delegate").ok_or_else(|| ParseError::Rule {
913 rule: trimmed.to_string(),
914 msg: "malformed `delegate(...)`".into(),
915 })?;
916 let parsed = parse_delegate_call_args(&inside, source)?;
917 return Ok(Step::Delegate(DelegateStep {
918 plugin_name: parsed.plugin_name,
919 config_override: parsed.config_override,
920 on_error: parsed.on_error,
921 source: source.to_string(),
922 }));
923 }
924
925 let rule = parse_rule(trimmed, source)?;
927 Ok(Step::Rule(rule))
928}
929
930struct ParsedDelegateCall {
934 plugin_name: String,
935 config_override: Option<serde_yaml::Value>,
936 on_error: Option<String>,
937}
938
939fn parse_delegate_call_args(inside: &str, source: &str) -> Result<ParsedDelegateCall, ParseError> {
957 let parts = split_top_level_commas(inside).map_err(|msg| ParseError::Rule {
958 rule: format!("delegate({inside})"),
959 msg: format!("{source}: {msg}"),
960 })?;
961 let mut parts_iter = parts.into_iter();
962
963 let plugin_name = parts_iter
964 .next()
965 .map(|s| s.trim().to_string())
966 .filter(|s| !s.is_empty())
967 .ok_or_else(|| ParseError::Rule {
968 rule: format!("delegate({inside})"),
969 msg: format!(
970 "{source}: `delegate(...)` requires a plugin name as the first \
971 positional argument"
972 ),
973 })?;
974 let plugin_name = strip_wrapping_quotes(&plugin_name).to_string();
976 if plugin_name.is_empty() {
977 return Err(ParseError::Rule {
978 rule: format!("delegate({inside})"),
979 msg: format!("{source}: `delegate(...)` plugin name cannot be empty"),
980 });
981 }
982
983 let mut on_error: Option<String> = None;
984 let mut config_map = serde_yaml::Mapping::new();
985
986 for raw_kwarg in parts_iter {
987 let kwarg = raw_kwarg.trim();
988 if kwarg.is_empty() {
989 continue;
990 }
991 let (key, value_str) = kwarg.split_once(':').ok_or_else(|| ParseError::Rule {
992 rule: kwarg.to_string(),
993 msg: format!(
994 "{source}: `delegate(...)` kwarg `{kwarg}` must be `key: value` \
995 (use the map form for richer config)"
996 ),
997 })?;
998 let key = key.trim();
999 let value_str = value_str.trim();
1000 if key.is_empty() {
1001 return Err(ParseError::Rule {
1002 rule: kwarg.to_string(),
1003 msg: format!("{source}: `delegate(...)` kwarg has empty key"),
1004 });
1005 }
1006 if key == "on_error" {
1007 let val = parse_delegate_value(value_str).map_err(|msg| ParseError::Rule {
1008 rule: kwarg.to_string(),
1009 msg: format!("{source}: on_error: {msg}"),
1010 })?;
1011 on_error = Some(
1012 val.as_str()
1013 .ok_or_else(|| ParseError::Rule {
1014 rule: kwarg.to_string(),
1015 msg: format!("{source}: `on_error` must be a string"),
1016 })?
1017 .to_string(),
1018 );
1019 continue;
1020 }
1021 if key == "plugin" {
1024 return Err(ParseError::Rule {
1025 rule: kwarg.to_string(),
1026 msg: format!(
1027 "{source}: `plugin` is set as the first positional argument \
1028 of `delegate(...)`; don't pass it as a kwarg too"
1029 ),
1030 });
1031 }
1032 let value = parse_delegate_value(value_str).map_err(|msg| ParseError::Rule {
1033 rule: kwarg.to_string(),
1034 msg: format!("{source}: `{key}`: {msg}"),
1035 })?;
1036 config_map.insert(serde_yaml::Value::String(key.to_string()), value);
1037 }
1038
1039 let config_override = if config_map.is_empty() {
1040 None
1041 } else {
1042 Some(serde_yaml::Value::Mapping(config_map))
1043 };
1044
1045 Ok(ParsedDelegateCall {
1046 plugin_name,
1047 config_override,
1048 on_error,
1049 })
1050}
1051
1052fn split_top_level_commas(input: &str) -> Result<Vec<String>, String> {
1060 let mut parts = Vec::new();
1061 let mut current = String::new();
1062 let mut bracket_depth: usize = 0;
1063 let mut quote: Option<char> = None;
1064 let mut escape = false;
1065
1066 for ch in input.chars() {
1067 if escape {
1068 current.push(ch);
1069 escape = false;
1070 continue;
1071 }
1072 if let Some(q) = quote {
1073 current.push(ch);
1074 if ch == '\\' {
1075 escape = true;
1076 } else if ch == q {
1077 quote = None;
1078 }
1079 continue;
1080 }
1081 match ch {
1082 '"' | '\'' => {
1083 quote = Some(ch);
1084 current.push(ch);
1085 },
1086 '[' | '(' | '{' => {
1087 bracket_depth += 1;
1088 current.push(ch);
1089 },
1090 ']' | ')' | '}' => {
1091 bracket_depth = bracket_depth
1092 .checked_sub(1)
1093 .ok_or_else(|| format!("unmatched `{ch}` in delegate(...) args"))?;
1094 current.push(ch);
1095 },
1096 ',' if bracket_depth == 0 => {
1097 parts.push(std::mem::take(&mut current));
1098 },
1099 _ => current.push(ch),
1100 }
1101 }
1102 if quote.is_some() {
1103 return Err("unterminated quoted string in delegate(...) args".to_string());
1104 }
1105 if bracket_depth != 0 {
1106 return Err("unbalanced brackets in delegate(...) args".to_string());
1107 }
1108 parts.push(current);
1109 Ok(parts)
1110}
1111
1112fn parse_delegate_value(s: &str) -> Result<serde_yaml::Value, String> {
1116 let trimmed = s.trim();
1117 if trimmed.is_empty() {
1118 return Err("empty value".to_string());
1119 }
1120 if let Some(stripped) = trimmed.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
1122 let items = split_top_level_commas(stripped)?;
1123 let mut out = Vec::with_capacity(items.len());
1124 for item in items {
1125 let item = item.trim();
1126 if item.is_empty() {
1127 continue;
1128 }
1129 out.push(parse_delegate_value(item)?);
1130 }
1131 return Ok(serde_yaml::Value::Sequence(out));
1132 }
1133 if (trimmed.starts_with('"') && trimmed.ends_with('"') && trimmed.len() >= 2)
1135 || (trimmed.starts_with('\'') && trimmed.ends_with('\'') && trimmed.len() >= 2)
1136 {
1137 return Ok(serde_yaml::Value::String(
1138 trimmed[1..trimmed.len() - 1].to_string(),
1139 ));
1140 }
1141 if trimmed == "true" {
1143 return Ok(serde_yaml::Value::Bool(true));
1144 }
1145 if trimmed == "false" {
1146 return Ok(serde_yaml::Value::Bool(false));
1147 }
1148 if let Ok(n) = trimmed.parse::<i64>() {
1150 return Ok(serde_yaml::Value::Number(serde_yaml::Number::from(n)));
1151 }
1152 if let Ok(f) = trimmed.parse::<f64>() {
1153 return Ok(serde_yaml::Value::Number(serde_yaml::Number::from(f)));
1154 }
1155 Ok(serde_yaml::Value::String(trimmed.to_string()))
1158}
1159
1160fn strip_wrapping_quotes(s: &str) -> &str {
1164 let bytes = s.as_bytes();
1165 if bytes.len() >= 2 {
1166 let first = bytes[0];
1167 let last = bytes[bytes.len() - 1];
1168 if (first == b'"' && last == b'"') || (first == b'\'' && last == b'\'') {
1169 return &s[1..s.len() - 1];
1170 }
1171 }
1172 s
1173}
1174
1175fn parse_step_map(m: &serde_yaml::Mapping, source: &str) -> Result<Step, ParseError> {
1176 if has_key(m, "when") && has_key(m, "do") {
1181 return parse_when_do_rule(m, source);
1182 }
1183
1184 if m.len() != 1 {
1185 return Err(ParseError::Rule {
1186 rule: format!("{:?}", m),
1187 msg: "step map must have exactly one key (PDP call signature, \
1188 `when:`/`do:`, or a `predicate: [effects...]` shorthand)"
1189 .into(),
1190 });
1191 }
1192 let (key_val, body_val) = m.iter().next().unwrap();
1193 let key = key_val.as_str().ok_or_else(|| ParseError::Rule {
1194 rule: format!("{:?}", key_val),
1195 msg: "PDP step key must be a string".into(),
1196 })?;
1197
1198 if let serde_yaml::Value::Sequence(items) = body_val {
1206 let trimmed = key.trim();
1213 if trimmed != "delegate"
1214 && trimmed != "sequential"
1215 && trimmed != "parallel"
1216 && !is_known_pdp_dialect(trimmed)
1217 {
1218 return parse_shorthand_multi_effect(trimmed, items, source);
1219 }
1220 }
1221
1222 if key.trim() == "delegate" {
1225 return parse_delegate_step(body_val, source);
1226 }
1227
1228 match key.trim() {
1232 "sequential" => {
1233 let effect = parse_sequential_effect(body_val, source)?;
1234 return Ok(Step::Rule(Rule {
1235 condition: Expression::Always,
1236 effects: vec![effect],
1237 source: source.to_string(),
1238 }));
1239 },
1240 "parallel" => {
1241 let effect = parse_parallel_effect(body_val, source)?;
1242 return Ok(Step::Rule(Rule {
1243 condition: Expression::Always,
1244 effects: vec![effect],
1245 source: source.to_string(),
1246 }));
1247 },
1248 _ => {},
1249 }
1250
1251 let (dialect_str, paren_args) = if let Some(open) = key.find('(') {
1253 let close = key.rfind(')').ok_or_else(|| ParseError::Rule {
1254 rule: key.to_string(),
1255 msg: "missing `)` in PDP call signature".into(),
1256 })?;
1257 let inside = key[open + 1..close].trim().to_string();
1258 (key[..open].trim(), Some(inside))
1259 } else {
1260 (key.trim(), None)
1261 };
1262
1263 let dialect = PdpDialect::from_key(dialect_str);
1264
1265 let body = body_val.as_mapping().ok_or_else(|| ParseError::Rule {
1269 rule: format!("{:?}", body_val),
1270 msg: format!(
1271 "`{}:` body must be a map (with on_deny / on_allow / args)",
1272 key
1273 ),
1274 })?;
1275
1276 let (args, on_deny, on_allow) = extract_pdp_body(body, paren_args.as_deref(), source)?;
1277
1278 Ok(Step::Pdp {
1279 call: PdpCall { dialect, args },
1280 on_deny,
1281 on_allow,
1282 })
1283}
1284
1285fn has_key(m: &serde_yaml::Mapping, key: &str) -> bool {
1304 m.contains_key(serde_yaml::Value::String(key.to_string()))
1305}
1306
1307fn is_known_pdp_dialect(key: &str) -> bool {
1311 let base = key.find('(').map(|i| &key[..i]).unwrap_or(key);
1312 matches!(base.trim(), "cedar" | "opa" | "authzen" | "nemo" | "cel")
1313}
1314
1315fn parse_when_do_rule(m: &serde_yaml::Mapping, source: &str) -> Result<Step, ParseError> {
1321 for (k, _) in m.iter() {
1326 let key = k.as_str().unwrap_or("");
1327 if !matches!(key, "when" | "do" | "id") {
1328 return Err(ParseError::Rule {
1329 rule: format!("{:?}", m),
1330 msg: format!(
1331 "unexpected key `{}` in when/do rule (allowed: `when`, `do`, `id`)",
1332 key
1333 ),
1334 });
1335 }
1336 }
1337
1338 let when_val = m
1339 .get(serde_yaml::Value::String("when".into()))
1340 .expect("has_key verified above");
1341 let predicate = when_val.as_str().ok_or_else(|| ParseError::Rule {
1342 rule: format!("{:?}", when_val),
1343 msg: "`when:` must be a predicate string".into(),
1344 })?;
1345 let condition = parse_predicate(predicate).map_err(|e| ParseError::Rule {
1346 rule: format!("when: {}", predicate),
1347 msg: format!("{}", e),
1348 })?;
1349
1350 let do_val = m
1351 .get(serde_yaml::Value::String("do".into()))
1352 .expect("has_key verified above");
1353 let effects = parse_do_body(do_val, source)?;
1354 if effects.is_empty() {
1355 return Err(ParseError::Rule {
1356 rule: format!("{:?}", m),
1357 msg: "`do:` produced no effects".into(),
1358 });
1359 }
1360
1361 Ok(Step::Rule(Rule {
1362 condition,
1363 effects,
1364 source: source.to_string(),
1365 }))
1366}
1367
1368fn parse_shorthand_multi_effect(
1372 predicate: &str,
1373 effect_list: &[serde_yaml::Value],
1374 source: &str,
1375) -> Result<Step, ParseError> {
1376 let condition = parse_predicate(predicate).map_err(|e| ParseError::Rule {
1377 rule: predicate.to_string(),
1378 msg: format!("{}", e),
1379 })?;
1380
1381 let mut effects = Vec::with_capacity(effect_list.len());
1382 for item in effect_list {
1383 effects.push(parse_effect_value(item, source)?);
1384 }
1385 if effects.is_empty() {
1386 return Err(ParseError::Rule {
1387 rule: predicate.to_string(),
1388 msg: "shorthand multi-effect map produced no effects".into(),
1389 });
1390 }
1391 Ok(Step::Rule(Rule {
1392 condition,
1393 effects,
1394 source: source.to_string(),
1395 }))
1396}
1397
1398fn parse_do_body(val: &serde_yaml::Value, source: &str) -> Result<Vec<Effect>, ParseError> {
1402 match val {
1403 serde_yaml::Value::String(s) => Ok(vec![parse_effect_string(s, source)?]),
1404 serde_yaml::Value::Sequence(items) => items
1405 .iter()
1406 .map(|item| parse_effect_value(item, source))
1407 .collect(),
1408 serde_yaml::Value::Mapping(_) => {
1409 Ok(vec![parse_effect_value(val, source)?])
1412 },
1413 other => Err(ParseError::Rule {
1414 rule: format!("{:?}", other),
1415 msg: "`do:` value must be a string, a list of effects, or an effect map".into(),
1416 }),
1417 }
1418}
1419
1420fn parse_effect_value(val: &serde_yaml::Value, source: &str) -> Result<Effect, ParseError> {
1424 match val {
1425 serde_yaml::Value::String(s) => parse_effect_string(s, source),
1426 serde_yaml::Value::Mapping(m) => {
1427 if m.len() == 1 {
1431 let (k, v) = m.iter().next().unwrap();
1432 if let Some(key_str) = k.as_str() {
1433 match key_str.trim() {
1434 "sequential" => return parse_sequential_effect(v, source),
1435 "parallel" => return parse_parallel_effect(v, source),
1436 _ => {},
1437 }
1438 }
1439 }
1440 let step = parse_step(val, source)?;
1443 step_to_effect(step, source)
1444 },
1445 other => Err(ParseError::Rule {
1446 rule: format!("{:?}", other),
1447 msg: "effect entry must be a string or a map".into(),
1448 }),
1449 }
1450}
1451
1452fn parse_sequential_effect(body: &serde_yaml::Value, source: &str) -> Result<Effect, ParseError> {
1455 let items = body.as_sequence().ok_or_else(|| ParseError::Rule {
1456 rule: format!("{:?}", body),
1457 msg: "`sequential:` body must be a list of effects".into(),
1458 })?;
1459 if items.is_empty() {
1460 return Err(ParseError::Rule {
1461 rule: format!("{:?}", body),
1462 msg: "`sequential:` body is empty".into(),
1463 });
1464 }
1465 let mut effects = Vec::with_capacity(items.len());
1466 for item in items {
1467 effects.push(parse_effect_value(item, source)?);
1468 }
1469 Ok(Effect::Sequential(effects))
1470}
1471
1472fn parse_parallel_effect(body: &serde_yaml::Value, source: &str) -> Result<Effect, ParseError> {
1476 let items = body.as_sequence().ok_or_else(|| ParseError::Rule {
1477 rule: format!("{:?}", body),
1478 msg: "`parallel:` body must be a list of effects".into(),
1479 })?;
1480 if items.is_empty() {
1481 return Err(ParseError::Rule {
1482 rule: format!("{:?}", body),
1483 msg: "`parallel:` body is empty".into(),
1484 });
1485 }
1486 let mut effects = Vec::with_capacity(items.len());
1487 for item in items {
1488 effects.push(parse_effect_value(item, source)?);
1489 }
1490 let parallel = Effect::Parallel(effects);
1491 parallel
1492 .validate_parallel_purity()
1493 .map_err(|msg| ParseError::Rule {
1494 rule: source.to_string(),
1495 msg,
1496 })?;
1497 Ok(parallel)
1498}
1499
1500fn parse_effect_string(s: &str, source: &str) -> Result<Effect, ParseError> {
1505 let trimmed = s.trim();
1510 if let Some(mut effects) = try_bare_action(trimmed) {
1511 if effects.len() == 1 {
1512 return Ok(effects.pop().unwrap());
1513 }
1514 }
1515 if let Some(effect) = try_parse_deny_call(trimmed, s)? {
1516 return Ok(effect);
1517 }
1518 if let Some(field_op) = try_parse_field_op(trimmed, s)? {
1523 return Ok(field_op);
1524 }
1525 let step = parse_step_string(s, source)?;
1528 step_to_effect(step, source)
1529}
1530
1531fn try_parse_field_op(s: &str, rule: &str) -> Result<Option<Effect>, ParseError> {
1535 let Some(pipe_idx) = find_top_level_pipe(s) else {
1536 return Ok(None);
1537 };
1538 let path = s[..pipe_idx].trim();
1539 let chain = s[pipe_idx + 1..].trim();
1540 if path.is_empty() || chain.is_empty() {
1541 return Ok(None);
1542 }
1543 if !is_valid_field_path(path) {
1548 return Ok(None);
1549 }
1550 let pipeline = parse_pipeline(chain).map_err(|e| ParseError::Rule {
1551 rule: rule.to_string(),
1552 msg: format!("field op `{}`: {}", path, e),
1553 })?;
1554 if pipeline.stages.is_empty() {
1555 return Err(ParseError::Rule {
1556 rule: rule.to_string(),
1557 msg: format!("field op `{}` has no stages", path),
1558 });
1559 }
1560 Ok(Some(Effect::FieldOp {
1561 path: path.to_string(),
1562 stages: pipeline.stages,
1563 }))
1564}
1565
1566fn find_top_level_pipe(s: &str) -> Option<usize> {
1570 let bytes = s.as_bytes();
1571 let mut depth: i32 = 0;
1572 let mut quote: Option<u8> = None;
1573 let mut i = 0;
1574 while i < bytes.len() {
1575 let b = bytes[i];
1576 if let Some(q) = quote {
1577 if b == b'\\' {
1578 i += 2;
1579 continue;
1580 }
1581 if b == q {
1582 quote = None;
1583 }
1584 i += 1;
1585 continue;
1586 }
1587 match b {
1588 b'\'' | b'"' => quote = Some(b),
1589 b'(' | b'[' => depth += 1,
1590 b')' | b']' => depth -= 1,
1591 b'|' if depth == 0 => {
1592 if bytes.get(i + 1) == Some(&b'|') {
1595 i += 2;
1596 continue;
1597 }
1598 return Some(i);
1599 },
1600 _ => {},
1601 }
1602 i += 1;
1603 }
1604 None
1605}
1606
1607fn is_valid_field_path(s: &str) -> bool {
1611 let Some(rest) = s
1612 .strip_prefix("args.")
1613 .or_else(|| s.strip_prefix("result."))
1614 else {
1615 return false;
1616 };
1617 !rest.is_empty()
1618 && rest
1619 .split('.')
1620 .all(|seg| !seg.is_empty() && seg.chars().all(|c| c.is_alphanumeric() || c == '_'))
1621}
1622
1623pub(crate) fn step_to_top_level_effect(step: Step) -> Result<Effect, ParseError> {
1634 match step {
1635 Step::Rule(rule) => Ok(Effect::When {
1636 condition: rule.condition,
1637 body: rule.effects,
1638 source: rule.source,
1639 }),
1640 Step::Pdp {
1641 call,
1642 on_allow,
1643 on_deny,
1644 } => {
1645 let on_allow = on_allow
1646 .into_iter()
1647 .map(step_to_top_level_effect)
1648 .collect::<Result<Vec<_>, _>>()?;
1649 let on_deny = on_deny
1650 .into_iter()
1651 .map(step_to_top_level_effect)
1652 .collect::<Result<Vec<_>, _>>()?;
1653 Ok(Effect::Pdp {
1654 call,
1655 on_allow,
1656 on_deny,
1657 })
1658 },
1659 Step::Plugin { name } => Ok(Effect::Plugin { name }),
1660 Step::Delegate(d) => Ok(Effect::Delegate(d)),
1661 Step::Taint { label, scopes } => Ok(Effect::Taint { label, scopes }),
1662 }
1663}
1664
1665fn step_to_effect(step: Step, source: &str) -> Result<Effect, ParseError> {
1666 match step {
1667 Step::Plugin { name } => Ok(Effect::Plugin { name }),
1668 Step::Delegate(d) => Ok(Effect::Delegate(d)),
1669 Step::Taint { label, scopes } => Ok(Effect::Taint { label, scopes }),
1670 Step::Rule(rule) => {
1671 if !matches!(rule.condition, Expression::Always) {
1674 return Err(ParseError::Rule {
1675 rule: source.to_string(),
1676 msg: "conditional rules nested inside `do:` are not supported in E1 \
1677 (use a sibling `when:`/`do:` rule instead)"
1678 .into(),
1679 });
1680 }
1681 if rule.effects.len() != 1 {
1682 return Err(ParseError::Rule {
1683 rule: source.to_string(),
1684 msg: format!(
1685 "unconditional rule inside `do:` must produce exactly one \
1686 effect, got {}",
1687 rule.effects.len()
1688 ),
1689 });
1690 }
1691 Ok(rule.effects.into_iter().next().unwrap())
1692 },
1693 Step::Pdp { .. } => Err(ParseError::Rule {
1694 rule: source.to_string(),
1695 msg: "PDP calls inside `do:` are not supported in E1 (use a sibling \
1696 step instead)"
1697 .into(),
1698 }),
1699 }
1700}
1701
1702fn parse_delegate_step(body_val: &serde_yaml::Value, source: &str) -> Result<Step, ParseError> {
1707 let body = body_val.as_mapping().ok_or_else(|| ParseError::Rule {
1708 rule: source.to_string(),
1709 msg: "`delegate:` body must be a map with `plugin:` and optional \
1710 `config:` / `on_error:`"
1711 .to_string(),
1712 })?;
1713
1714 let plugin = body
1715 .get(serde_yaml::Value::String("plugin".to_string()))
1716 .ok_or_else(|| ParseError::Rule {
1717 rule: source.to_string(),
1718 msg: "`delegate:` requires `plugin: <name>` referencing a \
1719 top-level plugin registered under `token.delegate`"
1720 .to_string(),
1721 })?;
1722 let plugin_name = plugin
1723 .as_str()
1724 .ok_or_else(|| ParseError::Rule {
1725 rule: source.to_string(),
1726 msg: "`delegate.plugin` must be a string".to_string(),
1727 })?
1728 .to_string();
1729 if plugin_name.is_empty() {
1730 return Err(ParseError::Rule {
1731 rule: source.to_string(),
1732 msg: "`delegate.plugin` cannot be empty".to_string(),
1733 });
1734 }
1735
1736 let config_override = body
1737 .get(serde_yaml::Value::String("config".to_string()))
1738 .cloned();
1739
1740 let on_error = match body.get(serde_yaml::Value::String("on_error".to_string())) {
1741 Some(v) => Some(
1742 v.as_str()
1743 .ok_or_else(|| ParseError::Rule {
1744 rule: source.to_string(),
1745 msg: "`delegate.on_error` must be a string (e.g. `deny`, \
1746 `continue`)"
1747 .to_string(),
1748 })?
1749 .to_string(),
1750 ),
1751 None => None,
1752 };
1753
1754 Ok(Step::Delegate(DelegateStep {
1755 plugin_name,
1756 config_override,
1757 on_error,
1758 source: source.to_string(),
1759 }))
1760}
1761
1762fn extract_pdp_body(
1769 body: &serde_yaml::Mapping,
1770 paren_args: Option<&str>,
1771 source: &str,
1772) -> Result<(serde_yaml::Value, Vec<Step>, Vec<Step>), ParseError> {
1773 let mut on_deny = Vec::new();
1774 let mut on_allow = Vec::new();
1775 let mut args_map = serde_yaml::Mapping::new();
1776
1777 for (k, v) in body {
1778 match k.as_str() {
1779 Some("on_deny") => {
1780 on_deny = parse_reaction_list(v, source, "on_deny")?;
1781 },
1782 Some("on_allow") => {
1783 on_allow = parse_reaction_list(v, source, "on_allow")?;
1784 },
1785 _ => {
1786 args_map.insert(k.clone(), v.clone());
1788 },
1789 }
1790 }
1791
1792 let args = match paren_args {
1793 Some(s) => serde_yaml::Value::String(s.to_string()),
1794 None => serde_yaml::Value::Mapping(args_map),
1795 };
1796
1797 Ok((args, on_deny, on_allow))
1798}
1799
1800fn parse_reaction_list(
1801 v: &serde_yaml::Value,
1802 source: &str,
1803 which: &str,
1804) -> Result<Vec<Step>, ParseError> {
1805 let list = v.as_sequence().ok_or_else(|| ParseError::Rule {
1806 rule: format!("{:?}", v),
1807 msg: format!("`{}:` must be a list of steps", which),
1808 })?;
1809 list.iter()
1810 .enumerate()
1811 .map(|(i, entry)| parse_step(entry, &format!("{}.{}[{}]", source, which, i)))
1812 .collect()
1813}
1814
1815fn extract_call_args(line: &str, name: &str) -> Option<String> {
1818 let line = line.trim();
1819 if !line.starts_with(name) {
1820 return None;
1821 }
1822 let after = &line[name.len()..];
1823 if !after.starts_with('(') {
1824 return None;
1825 }
1826 let bytes = after.as_bytes();
1828 let mut depth = 0;
1829 for (i, &b) in bytes.iter().enumerate() {
1830 match b {
1831 b'(' => depth += 1,
1832 b')' => {
1833 depth -= 1;
1834 if depth == 0 {
1835 if after[i + 1..].trim().is_empty() {
1837 return Some(after[1..i].to_string());
1838 }
1839 return None;
1840 }
1841 },
1842 _ => {},
1843 }
1844 }
1845 None
1846}
1847
1848pub fn parse_pipeline(src: &str) -> Result<Pipeline, ParseError> {
1858 let mut pipeline = Pipeline::new();
1859 for seg in split_top_level(src.trim(), b'|') {
1860 let seg = seg.trim();
1861 if seg.is_empty() {
1862 continue;
1863 }
1864 pipeline.push(parse_stage(seg)?);
1865 }
1866 Ok(pipeline)
1867}
1868
1869fn split_top_level(s: &str, delim: u8) -> Vec<&str> {
1871 let bytes = s.as_bytes();
1872 let mut out = Vec::new();
1873 let mut depth: i32 = 0;
1874 let mut in_quote: Option<u8> = None;
1875 let mut start = 0;
1876 for (i, &b) in bytes.iter().enumerate() {
1877 match (in_quote, b) {
1878 (Some(q), c) if c == q => in_quote = None,
1879 (Some(_), _) => {},
1880 (None, b'"') | (None, b'\'') => in_quote = Some(b),
1881 (None, b'(') | (None, b'[') => depth += 1,
1882 (None, b')') | (None, b']') => depth -= 1,
1883 (None, c) if c == delim && depth == 0 => {
1884 out.push(&s[start..i]);
1885 start = i + 1;
1886 },
1887 _ => {},
1888 }
1889 }
1890 out.push(&s[start..]);
1891 out
1892}
1893
1894fn parse_stage(src: &str) -> Result<Stage, ParseError> {
1895 let s = src.trim();
1896 let bad = |msg: &str| ParseError::Predicate {
1897 predicate: src.to_string(),
1898 msg: msg.to_string(),
1899 };
1900
1901 if let Some(stage) = try_parse_range(s) {
1903 return Ok(stage);
1904 }
1905
1906 let (head, args) = split_head_args(s).ok_or_else(|| bad("expected stage identifier"))?;
1909
1910 match (head, args.as_deref()) {
1911 ("str", None) => Ok(Stage::Type(TypeCheck::Str)),
1913 ("int", None) => Ok(Stage::Type(TypeCheck::Int)),
1914 ("bool", None) => Ok(Stage::Type(TypeCheck::Bool)),
1915 ("float", None) => Ok(Stage::Type(TypeCheck::Float)),
1916 ("email", None) => Ok(Stage::Type(TypeCheck::Email)),
1917 ("url", None) => Ok(Stage::Type(TypeCheck::Url)),
1918 ("uuid", None) => Ok(Stage::Type(TypeCheck::Uuid)),
1919 ("redact", None) => Ok(Stage::Redact { condition: None }),
1920 ("omit", None) => Ok(Stage::Omit),
1921 ("hash", None) => Ok(Stage::Hash),
1922 ("pii.redact", None) => Ok(Stage::Scan {
1924 kind: ScanKind::PiiRedact,
1925 }),
1926 ("pii.detect", None) => Ok(Stage::Scan {
1927 kind: ScanKind::PiiDetect,
1928 }),
1929 ("injection.scan", None) => Ok(Stage::Scan {
1930 kind: ScanKind::InjectionScan,
1931 }),
1932
1933 ("mask", Some(a)) => {
1935 let n: usize = a
1936 .trim()
1937 .parse()
1938 .map_err(|_| bad(&format!("mask(N) expects integer, got `{}`", a)))?;
1939 Ok(Stage::Mask { keep_last: n })
1940 },
1941 ("redact", Some(a)) => {
1942 let cond = parse_predicate(a).map_err(|e| ParseError::Predicate {
1944 predicate: src.to_string(),
1945 msg: format!("invalid redact() condition: {}", e),
1946 })?;
1947 Ok(Stage::Redact {
1948 condition: Some(cond),
1949 })
1950 },
1951 ("hash", Some(_)) => Err(bad("hash takes no arguments")),
1952 ("omit", Some(_)) => Err(bad(
1953 "omit takes no arguments — for conditional omit, use a policy rule predicate",
1954 )),
1955 ("len", Some(a)) => {
1956 let (min, max) = parse_range_inner(a)
1957 .ok_or_else(|| bad(&format!("len(...) expects N..M range, got `{}`", a)))?;
1958 let to_usize = |v: i64| -> Result<usize, ParseError> {
1959 if v < 0 {
1960 Err(bad("len bounds must be non-negative"))
1961 } else {
1962 Ok(v as usize)
1963 }
1964 };
1965 Ok(Stage::Length {
1966 min: min.map(to_usize).transpose()?,
1967 max: max.map(to_usize).transpose()?,
1968 })
1969 },
1970 ("enum", Some(a)) => {
1971 let values = split_top_level(a, b',')
1972 .into_iter()
1973 .map(|v| {
1974 let t = v.trim();
1975 if (t.starts_with('"') && t.ends_with('"'))
1977 || (t.starts_with('\'') && t.ends_with('\''))
1978 {
1979 t[1..t.len() - 1].to_string()
1980 } else {
1981 t.to_string()
1982 }
1983 })
1984 .filter(|s| !s.is_empty())
1985 .collect::<Vec<_>>();
1986 if values.is_empty() {
1987 return Err(bad("enum() requires at least one value"));
1988 }
1989 Ok(Stage::Enum { values })
1990 },
1991 ("regex", Some(a)) => {
1992 let pattern = a.trim();
1993 let pat = if (pattern.starts_with('"') && pattern.ends_with('"'))
1994 || (pattern.starts_with('\'') && pattern.ends_with('\''))
1995 {
1996 pattern[1..pattern.len() - 1].to_string()
1997 } else {
1998 pattern.to_string()
1999 };
2000 Ok(Stage::Regex { pattern: pat })
2001 },
2002 ("validate", Some(a)) => {
2003 Err(bad(&format!(
2017 "`validate({})` — named-validator dispatch is not implemented \
2018 in this build. Use `regex(\"pattern\")` for a named-regex \
2019 equivalent, or `plugin({})` for richer validation logic.",
2020 a.trim(),
2021 a.trim(),
2022 )))
2023 },
2024 ("plugin" | "run", Some(a)) => {
2026 let name = a.trim();
2027 if name.is_empty() {
2028 return Err(bad(&format!(
2032 "`{head}(...)`: plugin name must not be empty"
2033 )));
2034 }
2035 Ok(Stage::Plugin {
2036 name: name.to_string(),
2037 })
2038 },
2039 ("taint", Some(a)) => parse_taint(a, src),
2040
2041 (other, _) => Err(bad(&format!("unknown stage `{}`", other))),
2042 }
2043}
2044
2045fn try_parse_range(s: &str) -> Option<Stage> {
2047 if !s.contains("..") {
2048 return None;
2049 }
2050 let first = s.as_bytes().first().copied()?;
2052 if first.is_ascii_alphabetic() || first == b'_' {
2053 return None;
2054 }
2055 let (min, max) = parse_range_inner(s)?;
2056 Some(Stage::Range { min, max })
2057}
2058
2059fn parse_range_inner(s: &str) -> Option<(Option<i64>, Option<i64>)> {
2062 let dotdot = s.find("..")?;
2063 let left = s[..dotdot].trim();
2064 let right = s[dotdot + 2..].trim();
2065 let min = if left.is_empty() {
2066 None
2067 } else {
2068 Some(parse_numeric_with_suffix(left)?)
2069 };
2070 let max = if right.is_empty() {
2071 None
2072 } else {
2073 Some(parse_numeric_with_suffix(right)?)
2074 };
2075 if min.is_none() && max.is_none() {
2076 return None; }
2078 Some((min, max))
2079}
2080
2081fn parse_numeric_with_suffix(s: &str) -> Option<i64> {
2083 let s = s.trim();
2084 if s.is_empty() {
2085 return None;
2086 }
2087 let (num_part, mult) = match s.as_bytes().last().copied()? {
2088 b'k' | b'K' => (&s[..s.len() - 1], 1_000_i64),
2089 b'm' | b'M' => (&s[..s.len() - 1], 1_000_000_i64),
2090 _ => (s, 1_i64),
2091 };
2092 let n: i64 = num_part.parse().ok()?;
2093 n.checked_mul(mult)
2094}
2095
2096fn split_head_args(s: &str) -> Option<(&str, Option<String>)> {
2099 if let Some(open) = s.find('(') {
2100 let bytes = s.as_bytes();
2102 let mut depth = 0;
2103 let mut close = None;
2104 for (i, &b) in bytes.iter().enumerate().skip(open) {
2105 match b {
2106 b'(' => depth += 1,
2107 b')' => {
2108 depth -= 1;
2109 if depth == 0 {
2110 close = Some(i);
2111 break;
2112 }
2113 },
2114 _ => {},
2115 }
2116 }
2117 let close = close?;
2118 let head = s[..open].trim();
2119 if head.is_empty() {
2120 return None;
2121 }
2122 let args = s[open + 1..close].to_string();
2123 if s[close + 1..].trim().is_empty() {
2125 Some((head, Some(args)))
2126 } else {
2127 None
2128 }
2129 } else {
2130 let head = s.trim();
2131 if head.is_empty() {
2132 None
2133 } else {
2134 Some((head, None))
2135 }
2136 }
2137}
2138
2139fn parse_taint(args: &str, src: &str) -> Result<Stage, ParseError> {
2140 let parts = split_top_level(args, b',');
2142 if parts.is_empty() {
2143 return Err(ParseError::Predicate {
2144 predicate: src.to_string(),
2145 msg: "taint() requires at least a label".into(),
2146 });
2147 }
2148 let label = parts[0].trim().to_string();
2149 if label.is_empty() {
2150 return Err(ParseError::Predicate {
2151 predicate: src.to_string(),
2152 msg: "taint label must not be empty".into(),
2153 });
2154 }
2155
2156 let scopes = if parts.len() == 1 {
2157 vec![TaintScope::Session] } else {
2159 let scope_arg = parts[1..].join(",");
2160 let scope_arg = scope_arg.trim();
2161 if scope_arg.starts_with('[') && scope_arg.ends_with(']') {
2162 split_top_level(&scope_arg[1..scope_arg.len() - 1], b',')
2163 .into_iter()
2164 .map(|s| parse_taint_scope(s.trim(), src))
2165 .collect::<Result<Vec<_>, _>>()?
2166 } else {
2167 vec![parse_taint_scope(scope_arg, src)?]
2168 }
2169 };
2170
2171 Ok(Stage::Taint { label, scopes })
2172}
2173
2174fn parse_taint_scope(s: &str, src: &str) -> Result<TaintScope, ParseError> {
2175 match s {
2176 "session" => Ok(TaintScope::Session),
2177 "message" => Ok(TaintScope::Message),
2178 other => Err(ParseError::Predicate {
2179 predicate: src.to_string(),
2180 msg: format!(
2181 "unknown taint scope `{}` (expected `session` or `message`)",
2182 other
2183 ),
2184 }),
2185 }
2186}
2187
2188#[derive(Debug, Default, Deserialize)]
2202pub struct ConfigYaml {
2203 #[serde(default)]
2204 pub routes: HashMap<String, RouteYaml>,
2205
2206 #[serde(default)]
2208 pub plugins: Vec<PluginDeclaration>,
2209
2210 #[serde(flatten)]
2212 pub other: HashMap<String, serde_yaml::Value>,
2213}
2214
2215#[derive(Debug, Default, Deserialize)]
2216pub struct RouteYaml {
2217 #[serde(default)]
2222 pub pre_invocation: Vec<serde_yaml::Value>,
2223
2224 #[serde(default)]
2227 pub post_invocation: Vec<serde_yaml::Value>,
2228
2229 #[serde(default)]
2232 pub authorization: Option<AuthorizationYaml>,
2233
2234 #[serde(default)]
2236 pub args: HashMap<String, String>,
2237
2238 #[serde(default)]
2240 pub result: HashMap<String, String>,
2241
2242 #[serde(default)]
2246 pub plugins: HashMap<String, PluginOverride>,
2247
2248 #[serde(flatten)]
2252 pub other: HashMap<String, serde_yaml::Value>,
2253}
2254
2255#[derive(Debug, Default, Deserialize)]
2268#[serde(deny_unknown_fields)]
2269pub struct AuthorizationYaml {
2270 #[serde(default)]
2271 pub pre_invocation: Vec<serde_yaml::Value>,
2272
2273 #[serde(default)]
2274 pub post_invocation: Vec<serde_yaml::Value>,
2275}
2276
2277#[derive(Debug, Default)]
2284pub struct CompiledConfig {
2285 pub routes: HashMap<String, CompiledRoute>,
2286 pub plugins: PluginRegistry,
2287}
2288
2289pub fn compile_config(yaml: &str) -> Result<CompiledConfig, ParseError> {
2300 let cfg: ConfigYaml = serde_yaml::from_str(yaml)?;
2301 let mut routes = HashMap::with_capacity(cfg.routes.len());
2302 for (route_key, raw) in cfg.routes {
2303 if let Some(route) = compile_route(&route_key, raw)? {
2304 routes.insert(route_key, route);
2305 }
2306 }
2307 let mut plugins = PluginRegistry::with_capacity(cfg.plugins.len());
2308 for decl in cfg.plugins {
2309 plugins.insert(decl.name.clone(), decl);
2312 }
2313 Ok(CompiledConfig { routes, plugins })
2314}
2315
2316const RENAMED_FIELDS: [(&str, &str); 2] = [
2322 (
2323 "policy",
2324 "authorization.pre_invocation (or flat pre_invocation)",
2325 ),
2326 (
2327 "post_policy",
2328 "authorization.post_invocation (or flat post_invocation)",
2329 ),
2330];
2331
2332fn reject_legacy_keys(
2335 location: &str,
2336 other: &HashMap<String, serde_yaml::Value>,
2337) -> Result<(), ParseError> {
2338 for (old, new) in RENAMED_FIELDS {
2339 if other.contains_key(old) {
2340 return Err(ParseError::RenamedField {
2341 location: location.to_string(),
2342 old: old.to_string(),
2343 new: new.to_string(),
2344 });
2345 }
2346 }
2347 Ok(())
2348}
2349
2350fn compile_route(route_key: &str, raw: RouteYaml) -> Result<Option<CompiledRoute>, ParseError> {
2351 reject_legacy_keys(route_key, &raw.other)?;
2354 let has_authz = raw
2355 .authorization
2356 .as_ref()
2357 .is_some_and(|a| !a.pre_invocation.is_empty() || !a.post_invocation.is_empty());
2358 let has_apl = !raw.pre_invocation.is_empty()
2359 || !raw.post_invocation.is_empty()
2360 || has_authz
2361 || !raw.args.is_empty()
2362 || !raw.result.is_empty();
2363 if !has_apl {
2364 return Ok(None);
2365 }
2366 Ok(Some(compile_apl_blocks(route_key, raw)?))
2367}
2368
2369fn compile_apl_blocks(source: &str, raw: RouteYaml) -> Result<CompiledRoute, ParseError> {
2380 reject_legacy_keys(source, &raw.other)?;
2381 let mut route = CompiledRoute::new(source);
2382 let (auth_pre, auth_post) = raw
2383 .authorization
2384 .map(|a| (a.pre_invocation, a.post_invocation))
2385 .unwrap_or_default();
2386 if !auth_pre.is_empty() && !raw.pre_invocation.is_empty() {
2392 return Err(ParseError::ConflictingAuthorizationForms {
2393 location: source.to_string(),
2394 phase: "pre_invocation".to_string(),
2395 });
2396 }
2397 if !auth_post.is_empty() && !raw.post_invocation.is_empty() {
2398 return Err(ParseError::ConflictingAuthorizationForms {
2399 location: source.to_string(),
2400 phase: "post_invocation".to_string(),
2401 });
2402 }
2403 for (i, entry) in auth_pre.iter().chain(raw.pre_invocation.iter()).enumerate() {
2404 let step = parse_step(entry, &format!("{}.pre_invocation[{}]", source, i))?;
2405 route.policy.push(step_to_top_level_effect(step)?);
2406 }
2407 for (i, entry) in auth_post
2408 .iter()
2409 .chain(raw.post_invocation.iter())
2410 .enumerate()
2411 {
2412 let step = parse_step(entry, &format!("{}.post_invocation[{}]", source, i))?;
2413 route.post_policy.push(step_to_top_level_effect(step)?);
2414 }
2415 for (field, chain) in &raw.args {
2416 let pipeline = parse_pipeline(chain).map_err(|e| ParseError::Rule {
2417 rule: format!("args.{}: {:?}", field, chain),
2418 msg: format!("{}", e),
2419 })?;
2420 route.args.push(FieldRule {
2421 field: field.clone(),
2422 pipeline,
2423 source: format!("{}.args.{}", source, field),
2424 });
2425 }
2426 for (field, chain) in &raw.result {
2427 let pipeline = parse_pipeline(chain).map_err(|e| ParseError::Rule {
2428 rule: format!("result.{}: {:?}", field, chain),
2429 msg: format!("{}", e),
2430 })?;
2431 route.result.push(FieldRule {
2432 field: field.clone(),
2433 pipeline,
2434 source: format!("{}.result.{}", source, field),
2435 });
2436 }
2437 route.plugin_overrides = raw.plugins;
2438 Ok(route)
2439}
2440
2441pub fn compile_policy_block_value(
2463 source: &str,
2464 block: &serde_yaml::Value,
2465) -> Result<CompiledRoute, ParseError> {
2466 if block.is_null() {
2467 return Ok(CompiledRoute::new(source));
2468 }
2469 let raw: RouteYaml = serde_yaml::from_value(block.clone())?;
2470 compile_apl_blocks(source, raw)
2471}
2472
2473#[cfg(test)]
2478mod tests {
2479 use super::*;
2480 use crate::attributes::AttributeBag;
2481 use crate::evaluator::Decision;
2482
2483 #[test]
2486 fn lex_basic() {
2487 let toks = Lexer::new("delegation.depth > 2").tokenize_all().unwrap();
2488 assert_eq!(
2489 toks,
2490 vec![
2491 Tok::Ident("delegation.depth".into()),
2492 Tok::Gt,
2493 Tok::IntLit(2),
2494 ]
2495 );
2496 }
2497
2498 #[test]
2499 fn lex_strings_both_quotes() {
2500 let a = Lexer::new(r#""double""#).tokenize_all().unwrap();
2501 let b = Lexer::new(r#"'single'"#).tokenize_all().unwrap();
2502 assert_eq!(a, vec![Tok::StringLit("double".into())]);
2503 assert_eq!(b, vec![Tok::StringLit("single".into())]);
2504 }
2505
2506 #[test]
2507 fn lex_keywords_vs_idents() {
2508 let toks = Lexer::new("require(role.hr) & authenticated")
2509 .tokenize_all()
2510 .unwrap();
2511 assert_eq!(
2512 toks,
2513 vec![
2514 Tok::Require,
2515 Tok::LParen,
2516 Tok::Ident("role.hr".into()),
2517 Tok::RParen,
2518 Tok::And,
2519 Tok::Ident("authenticated".into()),
2520 ]
2521 );
2522 }
2523
2524 #[test]
2525 fn lex_rejects_single_equals() {
2526 let err = Lexer::new("a = 1").tokenize_all().unwrap_err();
2527 assert!(format!("{}", err).contains("expected `==`"));
2528 }
2529
2530 #[test]
2533 fn pred_bare_identifier() {
2534 let e = parse_predicate("authenticated").unwrap();
2535 assert_eq!(
2536 e,
2537 Expression::Condition(Condition::IsTrue {
2538 key: "authenticated".into()
2539 })
2540 );
2541 }
2542
2543 #[test]
2544 fn pred_comparison() {
2545 let e = parse_predicate("delegation.depth > 2").unwrap();
2546 assert_eq!(
2547 e,
2548 Expression::Condition(Condition::Comparison {
2549 key: "delegation.depth".into(),
2550 op: CompareOp::Gt,
2551 value: Literal::Int(2),
2552 })
2553 );
2554 }
2555
2556 #[test]
2557 fn pred_contains() {
2558 let e = parse_predicate(r#"session.labels contains "PII""#).unwrap();
2559 assert_eq!(
2560 e,
2561 Expression::Condition(Condition::Comparison {
2562 key: "session.labels".into(),
2563 op: CompareOp::Contains,
2564 value: Literal::String("PII".into()),
2565 })
2566 );
2567 }
2568
2569 #[test]
2570 fn pred_precedence_or_lowest_and_middle_not_highest() {
2571 let e = parse_predicate("!a & b | c").unwrap();
2573 match e {
2574 Expression::Or(parts) => {
2575 assert_eq!(parts.len(), 2);
2576 match &parts[0] {
2577 Expression::And(_) => {},
2578 other => panic!("first OR branch should be AND, got {:?}", other),
2579 }
2580 },
2581 other => panic!("top-level should be OR, got {:?}", other),
2582 }
2583 }
2584
2585 #[test]
2586 fn pred_parens_override_precedence() {
2587 let e = parse_predicate("(role.finance | role.admin) & !delegated").unwrap();
2589 match e {
2590 Expression::And(parts) => {
2591 assert_eq!(parts.len(), 2);
2592 matches!(parts[0], Expression::Or(_));
2593 matches!(parts[1], Expression::Not(_));
2594 },
2595 other => panic!("expected top-level AND, got {:?}", other),
2596 }
2597 }
2598
2599 #[test]
2600 fn pred_require_rejected_as_predicate() {
2601 let err = parse_predicate("require(authenticated)").unwrap_err();
2604 assert!(format!("{}", err).contains("rule-level shorthand"));
2605 }
2606
2607 #[test]
2608 fn rule_require_single_arg_desugars_to_isfalse_and_deny() {
2609 let r = parse_rule("require(authenticated)", "test").unwrap();
2611 assert!(matches!(
2612 r.effects.as_slice(),
2613 [Effect::Deny {
2614 reason: None,
2615 code: None
2616 }]
2617 ));
2618 assert_eq!(
2619 r.condition,
2620 Expression::Condition(Condition::IsFalse {
2621 key: "authenticated".into()
2622 }),
2623 );
2624 }
2625
2626 #[test]
2627 fn rule_require_comma_is_and_desugars_to_or_of_isfalse() {
2628 let r = parse_rule("require(role.hr, perm.view_ssn)", "test").unwrap();
2631 assert_eq!(
2632 r.condition,
2633 Expression::Or(vec![
2634 Expression::Condition(Condition::IsFalse {
2635 key: "role.hr".into()
2636 }),
2637 Expression::Condition(Condition::IsFalse {
2638 key: "perm.view_ssn".into()
2639 }),
2640 ]),
2641 );
2642 }
2643
2644 #[test]
2645 fn rule_require_pipe_is_or_desugars_to_and_of_isfalse() {
2646 let r = parse_rule("require(role.finance | role.admin)", "test").unwrap();
2649 assert_eq!(
2650 r.condition,
2651 Expression::And(vec![
2652 Expression::Condition(Condition::IsFalse {
2653 key: "role.finance".into()
2654 }),
2655 Expression::Condition(Condition::IsFalse {
2656 key: "role.admin".into()
2657 }),
2658 ]),
2659 );
2660 }
2661
2662 #[test]
2663 fn rule_require_mixed_rejected() {
2664 let err = parse_rule("require(a, b | c)", "test").unwrap_err();
2665 assert!(format!("{}", err).contains("cannot mix"));
2666 }
2667
2668 #[test]
2669 fn pred_eq_with_ident_rhs_rejected_with_in_hint() {
2670 let err = parse_predicate("subject.type == allowed_types").unwrap_err();
2673 let msg = format!("{}", err);
2674 assert!(msg.contains("RHS-as-identifier"));
2675 assert!(msg.contains("set membership use"));
2676 }
2677
2678 #[test]
2679 fn pred_in_set_basic() {
2680 let e = parse_predicate("subject.type in allowed_types").unwrap();
2681 assert_eq!(
2682 e,
2683 Expression::Condition(Condition::InSet {
2684 value_key: "subject.type".into(),
2685 set_key: "allowed_types".into(),
2686 negate: false,
2687 }),
2688 );
2689 }
2690
2691 #[test]
2692 fn pred_not_in_set() {
2693 let e = parse_predicate("subject.type not in blocked_types").unwrap();
2694 assert_eq!(
2695 e,
2696 Expression::Condition(Condition::InSet {
2697 value_key: "subject.type".into(),
2698 set_key: "blocked_types".into(),
2699 negate: true,
2700 }),
2701 );
2702 }
2703
2704 #[test]
2705 fn pred_exists_basic() {
2706 let e = parse_predicate("exists(args.amount)").unwrap();
2707 assert_eq!(
2708 e,
2709 Expression::Condition(Condition::Exists {
2710 key: "args.amount".into()
2711 }),
2712 );
2713 }
2714
2715 #[test]
2716 fn pred_exists_inside_compound() {
2717 let e = parse_predicate("exists(args.amount) & args.amount > 0").unwrap();
2719 match e {
2720 Expression::And(parts) => {
2721 assert_eq!(parts.len(), 2);
2722 assert_eq!(
2723 parts[0],
2724 Expression::Condition(Condition::Exists {
2725 key: "args.amount".into()
2726 }),
2727 );
2728 },
2729 other => panic!("expected And, got {:?}", other),
2730 }
2731 }
2732
2733 #[test]
2734 fn pred_exists_requires_paren_and_ident() {
2735 assert!(parse_predicate("exists").is_err());
2736 assert!(parse_predicate("exists()").is_err());
2737 assert!(parse_predicate("exists(authenticated").is_err());
2738 }
2739
2740 #[test]
2741 fn pred_trailing_tokens_rejected() {
2742 let err = parse_predicate("a b").unwrap_err();
2743 assert!(format!("{}", err).contains("trailing"));
2744 }
2745
2746 #[test]
2749 fn rule_predicate_action_form() {
2750 let r = parse_rule("delegation.depth > 2: deny", "test").unwrap();
2751 match r.effects.as_slice() {
2752 [Effect::Deny { .. }] => {},
2753 other => panic!("expected [Deny], got {:?}", other),
2754 }
2755 match r.condition {
2756 Expression::Condition(Condition::Comparison { .. }) => {},
2757 other => panic!("expected Comparison, got {:?}", other),
2758 }
2759 }
2760
2761 #[test]
2762 fn rule_predicate_only_defaults_to_deny() {
2763 let r = parse_rule("!authenticated", "test").unwrap();
2765 assert!(matches!(r.effects.as_slice(), [Effect::Deny { .. }]));
2766 }
2767
2768 #[test]
2769 fn rule_explicit_allow() {
2770 let r = parse_rule("role.admin: allow", "test").unwrap();
2771 assert!(matches!(r.effects.as_slice(), [Effect::Allow]));
2772 }
2773
2774 #[test]
2775 fn rule_bare_action_unconditional() {
2776 let r = parse_rule("deny", "test").unwrap();
2779 assert_eq!(r.condition, Expression::Always);
2780 assert!(matches!(
2781 r.effects.as_slice(),
2782 [Effect::Deny {
2783 reason: None,
2784 code: None
2785 }]
2786 ));
2787
2788 let r = parse_rule("allow", "test").unwrap();
2789 assert_eq!(r.condition, Expression::Always);
2790 assert!(matches!(r.effects.as_slice(), [Effect::Allow]));
2791 }
2792
2793 #[test]
2794 fn rule_bare_deny_call_carries_reason_and_code() {
2795 let r = parse_rule("deny('nope')", "test").unwrap();
2799 assert_eq!(r.condition, Expression::Always);
2800 match r.effects.as_slice() {
2801 [Effect::Deny {
2802 reason: Some(reason),
2803 code: None,
2804 }] => assert_eq!(reason, "nope"),
2805 other => panic!(
2806 "expected [Deny{{reason: Some, code: None}}], got {:?}",
2807 other
2808 ),
2809 }
2810
2811 let r = parse_rule("deny('nope', 'cel.policy')", "test").unwrap();
2812 assert_eq!(r.condition, Expression::Always);
2813 match r.effects.as_slice() {
2814 [Effect::Deny {
2815 reason: Some(reason),
2816 code: Some(code),
2817 }] => {
2818 assert_eq!(reason, "nope");
2819 assert_eq!(code, "cel.policy");
2820 },
2821 other => panic!("expected [Deny{{reason, code}}], got {:?}", other),
2822 }
2823 }
2824
2825 #[test]
2826 fn rule_malformed_bare_deny_call_errors() {
2827 let err = parse_rule("deny(unquoted)", "test").unwrap_err();
2830 assert!(
2831 matches!(err, ParseError::Rule { .. }),
2832 "expected ParseError::Rule, got {:?}",
2833 err
2834 );
2835 }
2836
2837 #[test]
2838 fn rule_step_kinds_rejected_clearly() {
2839 for s in [
2840 "plugin(rate_limiter)",
2841 "cedar:(action: read)",
2842 "opa(path)",
2843 "taint(audit)",
2844 ] {
2845 let err = parse_rule(s, "test").unwrap_err();
2846 assert!(
2847 matches!(err, ParseError::UnsupportedStep { .. }),
2848 "expected UnsupportedStep for `{}`, got {:?}",
2849 s,
2850 err
2851 );
2852 }
2853 }
2854
2855 #[test]
2856 fn rule_deny_with_unquoted_arg_rejected() {
2857 let err = parse_rule(r#"authenticated: deny "go away""#, "test").unwrap_err();
2862 assert!(format!("{}", err).contains("unsupported action"));
2863 }
2864
2865 #[test]
2866 fn rule_deny_with_quoted_reason_accepted() {
2867 let r = parse_rule(r#"delegation.depth > 2: deny('too deep')"#, "test").unwrap();
2870 assert!(matches!(
2871 r.effects.as_slice(),
2872 [Effect::Deny { reason: Some(s), code: None }] if s == "too deep"
2873 ));
2874 }
2875
2876 #[test]
2877 fn rule_deny_with_reason_and_code_accepted() {
2878 let r = parse_rule(
2881 r#"delegation.depth > 2: deny('too deep', 'delegation.depth_exceeded')"#,
2882 "test",
2883 )
2884 .unwrap();
2885 match r.effects.as_slice() {
2886 [Effect::Deny {
2887 reason: Some(reason),
2888 code: Some(code),
2889 }] => {
2890 assert_eq!(reason, "too deep");
2891 assert_eq!(code, "delegation.depth_exceeded");
2892 },
2893 other => panic!("expected Deny with reason+code, got {:?}", other),
2894 }
2895 }
2896
2897 #[test]
2898 fn rule_deny_with_too_many_args_rejected() {
2899 let err = parse_rule(r#"x: deny('a', 'b', 'c')"#, "test").unwrap_err();
2901 assert!(format!("{}", err).contains("at most two args"));
2902 }
2903
2904 #[test]
2905 fn rule_deny_with_unquoted_args_in_call_rejected() {
2906 let err = parse_rule(r#"x: deny(bare, identifier)"#, "test").unwrap_err();
2908 assert!(format!("{}", err).contains("expected a quoted string"));
2909 }
2910
2911 fn parse_step_yaml(yaml: &str) -> Result<Step, ParseError> {
2914 let v: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
2915 parse_step(&v, "test")
2916 }
2917
2918 #[test]
2919 fn when_do_single_effect_deny() {
2920 let step = parse_step_yaml("when: delegation.depth > 2\ndo: deny").unwrap();
2922 match step {
2923 Step::Rule(rule) => {
2924 assert!(matches!(
2925 rule.condition,
2926 Expression::Condition(Condition::Comparison { .. })
2927 ));
2928 assert!(matches!(
2929 rule.effects.as_slice(),
2930 [Effect::Deny {
2931 reason: None,
2932 code: None
2933 }]
2934 ));
2935 },
2936 other => panic!("expected Step::Rule, got {:?}", other),
2937 }
2938 }
2939
2940 #[test]
2941 fn when_do_single_effect_deny_with_reason_and_code() {
2942 let step = parse_step_yaml(
2944 "when: delegation.depth > 2\ndo: deny('too deep', 'delegation.depth_exceeded')",
2945 )
2946 .unwrap();
2947 let Step::Rule(rule) = step else {
2948 panic!("expected Step::Rule");
2949 };
2950 match rule.effects.as_slice() {
2951 [Effect::Deny {
2952 reason: Some(r),
2953 code: Some(c),
2954 }] => {
2955 assert_eq!(r, "too deep");
2956 assert_eq!(c, "delegation.depth_exceeded");
2957 },
2958 other => panic!("expected Deny+reason+code, got {:?}", other),
2959 }
2960 }
2961
2962 #[test]
2963 fn when_do_multi_effect_list() {
2964 let yaml = r#"
2967when: "!role.hr"
2968do:
2969 - "plugin(audit_logger)"
2970 - "taint(unauth, session)"
2971 - "deny('refused', 'role.hr_required')"
2972"#;
2973 let step = parse_step_yaml(yaml).unwrap();
2974 let Step::Rule(rule) = step else {
2975 panic!("expected Step::Rule");
2976 };
2977 assert_eq!(rule.effects.len(), 3);
2978 assert!(matches!(rule.effects[0], Effect::Plugin { ref name } if name == "audit_logger"));
2979 assert!(matches!(
2980 rule.effects[1],
2981 Effect::Taint { ref label, .. } if label == "unauth"
2982 ));
2983 match &rule.effects[2] {
2984 Effect::Deny {
2985 reason: Some(r),
2986 code: Some(c),
2987 } => {
2988 assert_eq!(r, "refused");
2989 assert_eq!(c, "role.hr_required");
2990 },
2991 other => panic!("expected Deny+reason+code, got {:?}", other),
2992 }
2993 }
2994
2995 #[test]
2996 fn when_do_key_order_does_not_matter() {
2997 let step = parse_step_yaml("do: deny\nwhen: delegation.depth > 2").unwrap();
2999 assert!(matches!(step, Step::Rule(_)));
3000 }
3001
3002 #[test]
3003 fn when_do_with_unknown_key_rejected() {
3004 let err = parse_step_yaml("when: x\ndo: deny\nwhne: typo").unwrap_err();
3006 assert!(format!("{}", err).contains("unexpected key"));
3007 }
3008
3009 #[test]
3010 fn when_do_empty_do_list_rejected() {
3011 let err = parse_step_yaml("when: x\ndo: []").unwrap_err();
3014 assert!(format!("{}", err).contains("no effects"));
3015 }
3016
3017 #[test]
3020 fn shorthand_multi_effect_map() {
3021 let yaml = r#"
3024"!role.hr":
3025 - "plugin(audit_logger)"
3026 - "deny('unauthorized')"
3027"#;
3028 let step = parse_step_yaml(yaml).unwrap();
3029 let Step::Rule(rule) = step else {
3030 panic!("expected Step::Rule");
3031 };
3032 assert_eq!(rule.effects.len(), 2);
3033 assert!(matches!(rule.effects[0], Effect::Plugin { ref name } if name == "audit_logger"));
3034 assert!(matches!(
3035 rule.effects[1],
3036 Effect::Deny { reason: Some(ref r), code: None } if r == "unauthorized"
3037 ));
3038 }
3039
3040 #[test]
3041 fn shorthand_multi_effect_map_with_nested_delegate() {
3042 let yaml = r#"
3045"role.hr":
3046 - delegate:
3047 plugin: workday-oauth
3048 config:
3049 audience: workday-api
3050 - "plugin(audit_logger)"
3051"#;
3052 let step = parse_step_yaml(yaml).unwrap();
3053 let Step::Rule(rule) = step else {
3054 panic!("expected Step::Rule");
3055 };
3056 assert_eq!(rule.effects.len(), 2);
3057 assert!(matches!(rule.effects[0], Effect::Delegate(_)));
3058 assert!(matches!(rule.effects[1], Effect::Plugin { .. }));
3059 }
3060
3061 #[test]
3062 fn cedar_with_list_body_still_parses_as_pdp() {
3063 let err = parse_step_yaml("cedar: [oh no]").unwrap_err();
3074 assert!(format!("{}", err).contains("body must be a map"));
3077 }
3078
3079 #[test]
3080 fn shorthand_multi_effect_empty_list_rejected() {
3081 let err = parse_step_yaml(r#""x": []"#).unwrap_err();
3082 assert!(format!("{}", err).contains("no effects"));
3083 }
3084
3085 #[test]
3088 fn when_do_with_field_op_result_redact() {
3089 let yaml = r#"
3092when: "!perm.view_ssn"
3093do:
3094 - "plugin(audit_logger)"
3095 - "result.salary | redact"
3096"#;
3097 let step = parse_step_yaml(yaml).unwrap();
3098 let Step::Rule(rule) = step else {
3099 panic!("expected Step::Rule");
3100 };
3101 assert_eq!(rule.effects.len(), 2);
3102 assert!(matches!(rule.effects[0], Effect::Plugin { .. }));
3103 match &rule.effects[1] {
3104 Effect::FieldOp { path, stages } => {
3105 assert_eq!(path, "result.salary");
3106 assert_eq!(stages.len(), 1, "single `redact` stage");
3107 },
3108 other => panic!("expected FieldOp, got {:?}", other),
3109 }
3110 }
3111
3112 #[test]
3113 fn when_do_with_field_op_args_mask() {
3114 let yaml = r#"
3116when: role.support
3117do: "args.card_number | mask(4)"
3118"#;
3119 let step = parse_step_yaml(yaml).unwrap();
3120 let Step::Rule(rule) = step else {
3121 panic!("expected Step::Rule");
3122 };
3123 match &rule.effects[..] {
3124 [Effect::FieldOp { path, stages }] => {
3125 assert_eq!(path, "args.card_number");
3126 assert_eq!(stages.len(), 1);
3127 },
3128 other => panic!("expected single FieldOp, got {:?}", other),
3129 }
3130 }
3131
3132 #[test]
3133 fn when_do_with_chained_field_op() {
3134 let yaml = r#"
3137when: role.support
3138do: "args.card_number | str | mask(4)"
3139"#;
3140 let step = parse_step_yaml(yaml).unwrap();
3141 let Step::Rule(rule) = step else {
3142 panic!("expected Step::Rule");
3143 };
3144 match &rule.effects[..] {
3145 [Effect::FieldOp { path, stages }] => {
3146 assert_eq!(path, "args.card_number");
3147 assert_eq!(stages.len(), 2, "two-stage chain");
3148 },
3149 other => panic!("expected single FieldOp, got {:?}", other),
3150 }
3151 }
3152
3153 #[test]
3154 fn field_stage_run_aliases_plugin() {
3155 let yaml = r#"
3158when: role.support
3159do: "args.card_number | run(luhn)"
3160"#;
3161 let step = parse_step_yaml(yaml).unwrap();
3162 let Step::Rule(rule) = step else {
3163 panic!("expected Step::Rule");
3164 };
3165 match &rule.effects[..] {
3166 [Effect::FieldOp { path, stages }] => {
3167 assert_eq!(path, "args.card_number");
3168 match &stages[..] {
3169 [Stage::Plugin { name }] => assert_eq!(name, "luhn"),
3170 other => panic!("expected [Stage::Plugin], got {:?}", other),
3171 }
3172 },
3173 other => panic!("expected single FieldOp, got {:?}", other),
3174 }
3175 }
3176
3177 #[test]
3178 fn field_stage_plugin_empty_name_is_rejected() {
3179 for verb in ["plugin", "run"] {
3184 let err = parse_stage(&format!("{verb}()")).expect_err("empty name must error");
3185 let msg = format!("{err}");
3186 assert!(
3187 msg.contains(verb) && msg.contains("must not be empty"),
3188 "{verb}(): expected verb-named empty-name error, got: {msg}"
3189 );
3190 }
3191 }
3192
3193 #[test]
3194 fn field_op_invalid_path_falls_through() {
3195 let yaml = r#"do: "role.hr | redact""#;
3200 let _ = parse_step_yaml(&format!("when: true\n{}", yaml));
3201 let step = parse_step_yaml("when: true\ndo: \"role.hr | redact\"");
3206 match step {
3207 Ok(Step::Rule(rule)) => {
3208 assert!(
3209 !matches!(rule.effects.as_slice(), [Effect::FieldOp { .. }]),
3210 "bare `role.hr` must NOT parse as a FieldOp path"
3211 );
3212 },
3213 Err(_) => {}, other => panic!("unexpected: {:?}", other),
3215 }
3216 }
3217
3218 #[test]
3219 fn field_op_empty_chain_rejected() {
3220 let yaml = r#"when: true
3222do: "args.x | ""#;
3223 let _ = parse_step_yaml(yaml); }
3225
3226 #[test]
3227 fn shorthand_multi_effect_with_field_op() {
3228 let yaml = r#"
3230"!perm.view_ssn":
3231 - "plugin(audit_logger)"
3232 - "result.ssn | redact"
3233"#;
3234 let step = parse_step_yaml(yaml).unwrap();
3235 let Step::Rule(rule) = step else {
3236 panic!("expected Step::Rule");
3237 };
3238 assert_eq!(rule.effects.len(), 2);
3239 assert!(matches!(rule.effects[1], Effect::FieldOp { .. }));
3240 }
3241
3242 #[test]
3243 fn find_top_level_pipe_skips_inside_parens() {
3244 assert_eq!(find_top_level_pipe("args.x | mask(4)"), Some(7));
3247 assert_eq!(find_top_level_pipe("validate(luhn)"), None);
3248 assert_eq!(find_top_level_pipe(r#"args.x | mask("a|b")"#), Some(7));
3249 assert_eq!(find_top_level_pipe("mask(a|b)"), None);
3251 }
3252
3253 #[test]
3256 fn top_level_sequential() {
3257 let yaml = r#"
3259sequential:
3260 - "plugin(rate_limiter)"
3261 - "plugin(audit_logger)"
3262"#;
3263 let step = parse_step_yaml(yaml).unwrap();
3264 let Step::Rule(rule) = step else {
3265 panic!("expected Rule");
3266 };
3267 assert!(matches!(rule.condition, Expression::Always));
3268 match rule.effects.as_slice() {
3269 [Effect::Sequential(inner)] => {
3270 assert_eq!(inner.len(), 2);
3271 assert!(matches!(inner[0], Effect::Plugin { .. }));
3272 assert!(matches!(inner[1], Effect::Plugin { .. }));
3273 },
3274 other => panic!("expected single Sequential effect, got {:?}", other),
3275 }
3276 }
3277
3278 #[test]
3279 fn top_level_parallel() {
3280 let yaml = r#"
3281parallel:
3282 - "plugin(pii_scanner)"
3283 - "plugin(nemo_guardrails)"
3284"#;
3285 let step = parse_step_yaml(yaml).unwrap();
3286 let Step::Rule(rule) = step else {
3287 panic!("expected Rule");
3288 };
3289 match rule.effects.as_slice() {
3290 [Effect::Parallel(inner)] => {
3291 assert_eq!(inner.len(), 2);
3292 },
3293 other => panic!("expected single Parallel effect, got {:?}", other),
3294 }
3295 }
3296
3297 #[test]
3298 fn parallel_inside_do_body() {
3299 let yaml = r#"
3302when: args.include_ssn == true
3303do:
3304 parallel:
3305 - "plugin(pii_scanner)"
3306 - "plugin(nemo_guardrails)"
3307"#;
3308 let step = parse_step_yaml(yaml).unwrap();
3309 let Step::Rule(rule) = step else {
3310 panic!("expected Rule");
3311 };
3312 match rule.effects.as_slice() {
3313 [Effect::Parallel(inner)] => assert_eq!(inner.len(), 2),
3314 other => panic!("expected Parallel in do:, got {:?}", other),
3315 }
3316 }
3317
3318 #[test]
3319 fn parallel_rejects_field_op_at_parse_time() {
3320 let yaml = r#"
3322parallel:
3323 - "plugin(audit)"
3324 - "args.ssn | redact"
3325"#;
3326 let err = parse_step_yaml(yaml).unwrap_err();
3327 assert!(format!("{}", err).contains("mutation"), "got: {}", err);
3328 }
3329
3330 #[test]
3331 fn parallel_rejects_delegate_at_parse_time() {
3332 let yaml = r#"
3333parallel:
3334 - "plugin(audit)"
3335 - "delegate(workday)"
3336"#;
3337 let err = parse_step_yaml(yaml).unwrap_err();
3338 assert!(format!("{}", err).contains("mutation"));
3339 }
3340
3341 #[test]
3342 fn sequential_allows_mutations() {
3343 let yaml = r#"
3345sequential:
3346 - "args.ssn | redact"
3347 - "plugin(audit)"
3348"#;
3349 let step = parse_step_yaml(yaml).unwrap();
3350 let Step::Rule(rule) = step else {
3351 panic!("expected Rule")
3352 };
3353 match rule.effects.as_slice() {
3354 [Effect::Sequential(inner)] => {
3355 assert!(matches!(inner[0], Effect::FieldOp { .. }));
3356 assert!(matches!(inner[1], Effect::Plugin { .. }));
3357 },
3358 other => panic!("got {:?}", other),
3359 }
3360 }
3361
3362 #[test]
3363 fn parallel_empty_list_rejected() {
3364 let err = parse_step_yaml("parallel: []").unwrap_err();
3365 assert!(format!("{}", err).contains("empty"));
3366 }
3367
3368 #[test]
3369 fn sequential_empty_list_rejected() {
3370 let err = parse_step_yaml("sequential: []").unwrap_err();
3371 assert!(format!("{}", err).contains("empty"));
3372 }
3373
3374 #[test]
3375 fn nested_orchestration() {
3376 let yaml = r#"
3379sequential:
3380 - "plugin(rate_limiter)"
3381 - parallel:
3382 - "plugin(pii_scanner)"
3383 - "plugin(nemo)"
3384"#;
3385 let step = parse_step_yaml(yaml).unwrap();
3386 let Step::Rule(rule) = step else {
3387 panic!("expected Rule")
3388 };
3389 let Effect::Sequential(outer) = &rule.effects[0] else {
3390 panic!("expected Sequential");
3391 };
3392 assert_eq!(outer.len(), 2);
3393 assert!(matches!(outer[0], Effect::Plugin { .. }));
3394 match &outer[1] {
3395 Effect::Parallel(inner) => assert_eq!(inner.len(), 2),
3396 other => panic!("expected nested Parallel, got {:?}", other),
3397 }
3398 }
3399
3400 #[test]
3403 fn split_respects_quotes_and_parens() {
3404 let r = parse_rule(r#"session.labels contains "a:b": deny"#, "test").unwrap();
3406 assert!(matches!(r.effects.as_slice(), [Effect::Deny { .. }]));
3407 if let Expression::Condition(Condition::Comparison { value, .. }) = r.condition {
3408 assert_eq!(value, Literal::String("a:b".into()));
3409 } else {
3410 panic!("expected Comparison");
3411 }
3412 }
3413
3414 #[test]
3417 fn compile_simple_route() {
3418 let yaml = r#"
3419routes:
3420 get_compensation:
3421 pre_invocation:
3422 - "require(authenticated)"
3423 - "require(role.hr | role.finance)"
3424 - "delegation.depth > 2 & include_ssn: deny"
3425"#;
3426 let routes = compile_config(yaml).unwrap().routes;
3427 let route = routes.get("get_compensation").expect("route missing");
3428 assert_eq!(route.policy.len(), 3);
3429 assert!(route
3430 .declared_phases()
3431 .contains(crate::rules::Phase::Policy));
3432 }
3433
3434 #[test]
3435 fn authorization_nested_and_flat_forms_are_equivalent() {
3436 let nested = r#"
3440routes:
3441 r:
3442 authorization:
3443 pre_invocation:
3444 - "require(authenticated)"
3445 post_invocation:
3446 - "taint(audit, session)"
3447"#;
3448 let flat = r#"
3449routes:
3450 r:
3451 pre_invocation:
3452 - "require(authenticated)"
3453 post_invocation:
3454 - "taint(audit, session)"
3455"#;
3456 let a = compile_config(nested).unwrap().routes;
3457 let b = compile_config(flat).unwrap().routes;
3458 let ra = a.get("r").expect("nested route");
3459 let rb = b.get("r").expect("flat route");
3460 assert_eq!(ra.policy.len(), rb.policy.len());
3461 assert_eq!(ra.post_policy.len(), rb.post_policy.len());
3462 assert_eq!(ra.policy.len(), 1);
3463 assert_eq!(ra.post_policy.len(), 1);
3464 }
3465
3466 #[test]
3467 fn legacy_key_nested_under_authorization_is_rejected() {
3468 let yaml = r#"
3473routes:
3474 r:
3475 authorization:
3476 policy:
3477 - "require(authenticated)"
3478"#;
3479 let err = compile_config(yaml).expect_err("nested legacy `policy:` must be rejected");
3480 let msg = format!("{err}");
3481 assert!(
3482 msg.contains("policy") || msg.contains("unknown field"),
3483 "error should flag the unknown/legacy nested key: {msg}"
3484 );
3485 }
3486
3487 #[test]
3488 fn authorization_typo_under_wrapper_is_rejected() {
3489 let yaml = r#"
3492routes:
3493 r:
3494 authorization:
3495 pre_invocaton:
3496 - "require(authenticated)"
3497"#;
3498 assert!(
3499 compile_config(yaml).is_err(),
3500 "a typo'd sub-key under `authorization:` must be rejected, not ignored"
3501 );
3502 }
3503
3504 #[test]
3505 fn same_phase_declared_nested_and_flat_is_rejected() {
3506 let yaml = r#"
3509routes:
3510 r:
3511 authorization:
3512 pre_invocation:
3513 - "require(authenticated)"
3514 pre_invocation:
3515 - "require(role.hr)"
3516"#;
3517 let err = compile_config(yaml).expect_err("both-forms-same-section must be rejected");
3518 assert!(
3519 matches!(err, ParseError::ConflictingAuthorizationForms { ref phase, .. } if phase == "pre_invocation"),
3520 "expected ConflictingAuthorizationForms for pre_invocation, got {err:?}"
3521 );
3522 }
3523
3524 #[test]
3525 fn field_pipeline_error_names_field_path() {
3526 let yaml = r#"
3529routes:
3530 r:
3531 result:
3532 x: "nonsense"
3533"#;
3534 let err = compile_config(yaml).unwrap_err();
3535 let msg = format!("{err}");
3536 assert!(msg.contains("result.x"), "expected result.x in: {msg}");
3537 }
3538
3539 #[test]
3540 fn legacy_policy_field_names_are_rejected() {
3541 for (old, hint) in [
3544 ("policy", "pre_invocation"),
3545 ("post_policy", "post_invocation"),
3546 ] {
3547 let yaml = format!("routes:\n r:\n {old}:\n - \"require(authenticated)\"\n");
3548 let err = compile_config(&yaml).expect_err(&format!("legacy `{old}` must be rejected"));
3549 let msg = format!("{err}");
3550 assert!(
3551 msg.contains(old) && msg.contains(hint),
3552 "`{old}` rejection should name the replacement `{hint}`: {msg}"
3553 );
3554 }
3555 }
3556
3557 #[test]
3558 fn legacy_only_route_is_not_silently_omitted() {
3559 let yaml = r#"
3563routes:
3564 ghost:
3565 policy:
3566 - "require(authenticated)"
3567"#;
3568 assert!(
3569 matches!(compile_config(yaml), Err(ParseError::RenamedField { .. })),
3570 "legacy-only route must be rejected, not omitted"
3571 );
3572 }
3573
3574 #[test]
3575 fn compile_omits_routes_without_apl_blocks() {
3576 let yaml = r#"
3582routes:
3583 legacy:
3584 priority: 50
3585 apl_route:
3586 pre_invocation:
3587 - "require(authenticated)"
3588"#;
3589 let routes = compile_config(yaml).unwrap().routes;
3590 assert!(routes.contains_key("apl_route"));
3591 assert!(
3592 !routes.contains_key("legacy"),
3593 "legacy route should be omitted, not compiled"
3594 );
3595 }
3596
3597 #[test]
3598 fn compile_unknown_top_level_keys_ignored() {
3599 let yaml = r#"
3600version: "0.1"
3601policy_evaluator:
3602 kind: apl
3603plugins:
3604 - name: rate_limiter
3605 kind: native
3606imports:
3607 - "./shared.yaml"
3608routes:
3609 ping:
3610 pre_invocation:
3611 - "require(authenticated)"
3612"#;
3613 let routes = compile_config(yaml).unwrap().routes;
3614 assert!(routes.contains_key("ping"));
3615 }
3616
3617 #[test]
3618 fn compile_propagates_rule_errors_with_source() {
3619 let yaml = r#"
3620routes:
3621 bad:
3622 pre_invocation:
3623 - "subject.id == garbage_ident"
3624"#;
3625 let err = compile_config(yaml).unwrap_err();
3626 let msg = format!("{}", err);
3628 assert!(
3629 msg.contains("RHS-as-identifier") || msg.contains("garbage_ident"),
3630 "error message should reference the failure: {}",
3631 msg,
3632 );
3633 }
3634
3635 #[test]
3636 fn compile_plugin_step_string_form() {
3637 let yaml = r#"
3638routes:
3639 rate_limited:
3640 pre_invocation:
3641 - "plugin(rate_limiter)"
3642"#;
3643 let routes = compile_config(yaml).unwrap().routes;
3644 let route = routes.get("rate_limited").unwrap();
3645 assert_eq!(route.policy.len(), 1);
3646 match &route.policy[0] {
3647 Effect::Plugin { name } => assert_eq!(name, "rate_limiter"),
3648 other => panic!("expected Effect::Plugin, got {:?}", other),
3649 }
3650 }
3651
3652 #[test]
3653 fn compile_run_step_string_form_aliases_plugin() {
3654 let yaml = r#"
3657routes:
3658 rate_limited:
3659 pre_invocation:
3660 - "run(rate_limiter)"
3661"#;
3662 let routes = compile_config(yaml).unwrap().routes;
3663 let route = routes.get("rate_limited").unwrap();
3664 assert_eq!(route.policy.len(), 1);
3665 match &route.policy[0] {
3666 Effect::Plugin { name } => assert_eq!(name, "rate_limiter"),
3667 other => panic!("expected Effect::Plugin, got {:?}", other),
3668 }
3669 }
3670
3671 #[test]
3672 fn parse_step_run_is_plugin_alias() {
3673 for s in ["run(audit-log)", "plugin(audit-log)"] {
3674 let step = parse_step(&serde_yaml::Value::String(s.to_string()), "test").unwrap();
3675 match step {
3676 crate::step::Step::Plugin { name } => assert_eq!(name, "audit-log", "{s}"),
3677 other => panic!("expected Step::Plugin for `{s}`, got {other:?}"),
3678 }
3679 }
3680 let err = parse_step(&serde_yaml::Value::String("run()".to_string()), "test").unwrap_err();
3682 assert!(
3683 format!("{err}").contains("run("),
3684 "error should name `run(...)`: {err}"
3685 );
3686 }
3687
3688 #[test]
3689 fn compile_taint_step_string_form() {
3690 let yaml = r#"
3691routes:
3692 audit_marked:
3693 pre_invocation:
3694 - "taint(audit, session)"
3695"#;
3696 let routes = compile_config(yaml).unwrap().routes;
3697 let route = routes.get("audit_marked").unwrap();
3698 match &route.policy[0] {
3699 Effect::Taint { label, scopes } => {
3700 assert_eq!(label, "audit");
3701 assert_eq!(scopes, &vec![TaintScope::Session]);
3702 },
3703 other => panic!("expected Effect::Taint, got {:?}", other),
3704 }
3705 }
3706
3707 #[test]
3708 fn compile_pdp_call_cedar_map_form() {
3709 let yaml = r#"
3711routes:
3712 authz_check:
3713 pre_invocation:
3714 - cedar:
3715 action: read
3716 resource: employee
3717 on_deny:
3718 - deny
3719 on_allow:
3720 - "plugin(audit_logger)"
3721"#;
3722 let routes = compile_config(yaml).unwrap().routes;
3723 let route = routes.get("authz_check").unwrap();
3724 match &route.policy[0] {
3725 Effect::Pdp {
3726 call,
3727 on_deny,
3728 on_allow,
3729 } => {
3730 assert_eq!(call.dialect, PdpDialect::Cedar);
3731 let args_map = call.args.as_mapping().expect("cedar args should be a map");
3734 assert!(args_map.contains_key(serde_yaml::Value::String("action".into())));
3735 assert!(args_map.contains_key(serde_yaml::Value::String("resource".into())));
3736 assert!(!args_map.contains_key(serde_yaml::Value::String("on_deny".into())));
3737 assert_eq!(on_deny.len(), 1);
3738 assert_eq!(on_allow.len(), 1);
3739 },
3740 other => panic!("expected Effect::Pdp, got {:?}", other),
3741 }
3742 }
3743
3744 #[test]
3745 fn compile_pdp_call_cel_map_form() {
3746 let yaml = r#"
3749routes:
3750 authz_check:
3751 pre_invocation:
3752 - cel:
3753 expr: "subject.id == 'alice' && delegation.depth <= 2"
3754 on_deny:
3755 - deny
3756"#;
3757 let routes = compile_config(yaml).unwrap().routes;
3758 let route = routes.get("authz_check").unwrap();
3759 match &route.policy[0] {
3760 Effect::Pdp {
3761 call,
3762 on_deny,
3763 on_allow,
3764 } => {
3765 assert_eq!(call.dialect, PdpDialect::Cel);
3766 let args_map = call.args.as_mapping().expect("cel args should be a map");
3767 assert!(args_map.contains_key(serde_yaml::Value::String("expr".into())));
3768 assert!(!args_map.contains_key(serde_yaml::Value::String("on_deny".into())));
3770 assert_eq!(on_deny.len(), 1);
3771 assert_eq!(on_allow.len(), 0);
3772 },
3773 other => panic!("expected Effect::Pdp, got {:?}", other),
3774 }
3775 }
3776
3777 #[test]
3778 fn compile_pdp_call_opa_paren_form() {
3779 let yaml = r#"
3781routes:
3782 opa_check:
3783 pre_invocation:
3784 - 'opa("hr/compensation/deny"):':
3785 on_deny:
3786 - deny
3787"#;
3788 let routes = compile_config(yaml).unwrap().routes;
3789 let route = routes.get("opa_check").unwrap();
3790 match &route.policy[0] {
3791 Effect::Pdp { call, on_deny, .. } => {
3792 assert_eq!(call.dialect, PdpDialect::Opa);
3793 assert!(call.args.as_str().unwrap().contains("hr/compensation/deny"));
3795 assert_eq!(on_deny.len(), 1);
3796 },
3797 other => panic!("expected Effect::Pdp, got {:?}", other),
3798 }
3799 }
3800
3801 #[test]
3802 fn compile_pdp_unknown_dialect_becomes_custom() {
3803 let yaml = r#"
3804routes:
3805 custom_pdp:
3806 pre_invocation:
3807 - my_engine:
3808 on_deny: [deny]
3809"#;
3810 let routes = compile_config(yaml).unwrap().routes;
3811 match &routes.get("custom_pdp").unwrap().policy[0] {
3812 Effect::Pdp { call, .. } => {
3813 assert_eq!(call.dialect, PdpDialect::Custom("my_engine".into()));
3814 },
3815 other => panic!("expected Pdp, got {:?}", other),
3816 }
3817 }
3818
3819 #[tokio::test]
3822 async fn end_to_end_hr_compensation() {
3823 let yaml = r#"
3824routes:
3825 get_compensation:
3826 pre_invocation:
3827 - "require(authenticated)"
3828 - "require(role.hr | role.finance)"
3829 - "delegation.depth > 2: deny"
3830"#;
3831 let routes = compile_config(yaml).unwrap().routes;
3832 let route = routes.get("get_compensation").unwrap();
3833
3834 let pdp: std::sync::Arc<dyn crate::PdpResolver> = std::sync::Arc::new(NullPdpResolver);
3835 let plugins: std::sync::Arc<dyn crate::PluginInvoker> =
3836 std::sync::Arc::new(NullPluginInvoker);
3837 let delegations: std::sync::Arc<dyn crate::DelegationInvoker> =
3838 std::sync::Arc::new(crate::NoopDelegationInvoker);
3839
3840 let mut bag = AttributeBag::new();
3842 bag.set("authenticated", true);
3843 bag.set("role.hr", true);
3844 bag.set("delegation.depth", 1_i64);
3845 assert_eq!(
3846 crate::evaluate_effects(
3847 &route.policy,
3848 &mut bag,
3849 &pdp,
3850 &plugins,
3851 &delegations,
3852 crate::DispatchPhase::Pre,
3853 &mut crate::route::RoutePayload::new(serde_json::Value::Null)
3854 )
3855 .await
3856 .decision,
3857 Decision::Allow,
3858 );
3859
3860 bag.set("delegation.depth", 3_i64);
3862 match crate::evaluate_effects(
3863 &route.policy,
3864 &mut bag,
3865 &pdp,
3866 &plugins,
3867 &delegations,
3868 crate::DispatchPhase::Pre,
3869 &mut crate::route::RoutePayload::new(serde_json::Value::Null),
3870 )
3871 .await
3872 .decision
3873 {
3874 Decision::Deny { rule_source, .. } => {
3875 assert!(
3876 rule_source.contains("pre_invocation[2]"),
3877 "expected pre_invocation[2], got {}",
3878 rule_source
3879 );
3880 },
3881 d => panic!("expected Deny, got {:?}", d),
3882 }
3883
3884 let mut bag = AttributeBag::new();
3886 bag.set("authenticated", true);
3887 bag.set("delegation.depth", 1_i64);
3888 match crate::evaluate_effects(
3889 &route.policy,
3890 &mut bag,
3891 &pdp,
3892 &plugins,
3893 &delegations,
3894 crate::DispatchPhase::Pre,
3895 &mut crate::route::RoutePayload::new(serde_json::Value::Null),
3896 )
3897 .await
3898 .decision
3899 {
3900 Decision::Deny { rule_source, .. } => {
3901 assert!(
3902 rule_source.contains("pre_invocation[1]"),
3903 "expected pre_invocation[1], got {}",
3904 rule_source
3905 );
3906 },
3907 d => panic!("expected Deny, got {:?}", d),
3908 }
3909 }
3910
3911 struct NullPdpResolver;
3914 #[async_trait::async_trait]
3915 impl crate::PdpResolver for NullPdpResolver {
3916 fn dialect(&self) -> crate::PdpDialect {
3917 crate::PdpDialect::Cedar
3918 }
3919 async fn evaluate(
3920 &self,
3921 _call: &crate::PdpCall,
3922 _bag: &crate::AttributeBag,
3923 ) -> Result<crate::PdpDecision, crate::PdpError> {
3924 panic!("NullPdpResolver should not be invoked in pure-rule tests");
3925 }
3926 }
3927
3928 struct NullPluginInvoker;
3929 #[async_trait::async_trait]
3930 impl crate::PluginInvoker for NullPluginInvoker {
3931 async fn invoke(
3932 &self,
3933 _name: &str,
3934 _bag: &crate::AttributeBag,
3935 _invocation: crate::PluginInvocation<'_>,
3936 ) -> Result<crate::PluginOutcome, crate::PluginError> {
3937 panic!("NullPluginInvoker should not be invoked in pure-rule tests");
3938 }
3939 }
3940
3941 #[test]
3944 fn pipeline_simple_bare_stages() {
3945 let p = parse_pipeline("str").unwrap();
3946 assert_eq!(p.stages, vec![Stage::Type(TypeCheck::Str)]);
3947
3948 let p = parse_pipeline("omit").unwrap();
3949 assert_eq!(p.stages, vec![Stage::Omit]);
3950
3951 let p = parse_pipeline("hash").unwrap();
3952 assert_eq!(p.stages, vec![Stage::Hash]);
3953 }
3954
3955 #[test]
3956 fn pipeline_chains_split_on_pipe() {
3957 let p = parse_pipeline("str | mask(4)").unwrap();
3958 assert_eq!(
3959 p.stages,
3960 vec![Stage::Type(TypeCheck::Str), Stage::Mask { keep_last: 4 },]
3961 );
3962
3963 let p = parse_pipeline("int | 0..1M").unwrap();
3964 assert_eq!(
3965 p.stages,
3966 vec![
3967 Stage::Type(TypeCheck::Int),
3968 Stage::Range {
3969 min: Some(0),
3970 max: Some(1_000_000)
3971 },
3972 ]
3973 );
3974 }
3975
3976 #[test]
3977 fn pipeline_pipe_inside_parens_does_not_split() {
3978 let p = parse_pipeline("str | redact(!perm.view_ssn | role.admin)").unwrap();
3981 assert_eq!(p.stages.len(), 2);
3982 match &p.stages[1] {
3983 Stage::Redact { condition: Some(_) } => {},
3984 other => panic!("expected Redact with condition, got {:?}", other),
3985 }
3986 }
3987
3988 #[test]
3989 fn pipeline_length_constraints() {
3990 let p = parse_pipeline("len(..500)").unwrap();
3991 assert_eq!(
3992 p.stages,
3993 vec![Stage::Length {
3994 min: None,
3995 max: Some(500)
3996 }]
3997 );
3998 let p = parse_pipeline("len(10..50)").unwrap();
3999 assert_eq!(
4000 p.stages,
4001 vec![Stage::Length {
4002 min: Some(10),
4003 max: Some(50)
4004 }]
4005 );
4006 let p = parse_pipeline("len(8..)").unwrap();
4007 assert_eq!(
4008 p.stages,
4009 vec![Stage::Length {
4010 min: Some(8),
4011 max: None
4012 }]
4013 );
4014 }
4015
4016 #[test]
4017 fn pipeline_range_with_suffixes() {
4018 let p = parse_pipeline("0..10k").unwrap();
4019 assert_eq!(
4020 p.stages,
4021 vec![Stage::Range {
4022 min: Some(0),
4023 max: Some(10_000)
4024 }]
4025 );
4026 let p = parse_pipeline("0..1M").unwrap();
4027 assert_eq!(
4028 p.stages,
4029 vec![Stage::Range {
4030 min: Some(0),
4031 max: Some(1_000_000)
4032 }]
4033 );
4034 let p = parse_pipeline("..500").unwrap();
4035 assert_eq!(
4036 p.stages,
4037 vec![Stage::Range {
4038 min: None,
4039 max: Some(500)
4040 }]
4041 );
4042 }
4043
4044 #[test]
4045 fn pipeline_enum_unquoted_and_quoted() {
4046 let p = parse_pipeline("enum(low, medium, high)").unwrap();
4047 assert_eq!(
4048 p.stages,
4049 vec![Stage::Enum {
4050 values: vec!["low".into(), "medium".into(), "high".into()],
4051 }]
4052 );
4053 let p = parse_pipeline(r#"enum("a", "b")"#).unwrap();
4054 assert_eq!(
4055 p.stages,
4056 vec![Stage::Enum {
4057 values: vec!["a".into(), "b".into()],
4058 }]
4059 );
4060 }
4061
4062 #[test]
4063 fn pipeline_redact_with_predicate_condition() {
4064 let p = parse_pipeline("str | redact(!perm.view_ssn)").unwrap();
4065 assert_eq!(p.stages.len(), 2);
4066 match &p.stages[1] {
4067 Stage::Redact {
4068 condition: Some(Expression::Not(inner)),
4069 } => match inner.as_ref() {
4070 Expression::Condition(Condition::IsTrue { key }) => {
4071 assert_eq!(key, "perm.view_ssn");
4072 },
4073 other => panic!("expected IsTrue(perm.view_ssn), got {:?}", other),
4074 },
4075 other => panic!("expected Redact with Not condition, got {:?}", other),
4076 }
4077 }
4078
4079 #[test]
4080 fn pipeline_taint_scopes() {
4081 let p = parse_pipeline("taint(PII)").unwrap();
4082 assert_eq!(
4083 p.stages,
4084 vec![Stage::Taint {
4085 label: "PII".into(),
4086 scopes: vec![TaintScope::Session],
4087 }]
4088 );
4089 let p = parse_pipeline("taint(PII, message)").unwrap();
4090 assert_eq!(
4091 p.stages,
4092 vec![Stage::Taint {
4093 label: "PII".into(),
4094 scopes: vec![TaintScope::Message],
4095 }]
4096 );
4097 let p = parse_pipeline("taint(PII, [session, message])").unwrap();
4098 assert_eq!(
4099 p.stages,
4100 vec![Stage::Taint {
4101 label: "PII".into(),
4102 scopes: vec![TaintScope::Session, TaintScope::Message],
4103 }]
4104 );
4105 }
4106
4107 #[test]
4108 fn pipeline_unknown_stage_rejected() {
4109 let err = parse_pipeline("nonsense").unwrap_err();
4110 assert!(format!("{}", err).contains("unknown stage"));
4111 }
4112
4113 #[test]
4114 fn pipeline_omit_with_args_rejected() {
4115 let err = parse_pipeline("omit(!perm.x)").unwrap_err();
4117 assert!(format!("{}", err).contains("omit takes no arguments"));
4118 }
4119
4120 #[test]
4123 fn compile_route_with_args_and_result() {
4124 let yaml = r#"
4125routes:
4126 get_compensation:
4127 args:
4128 employee_id: "uuid"
4129 amount: "int | 0..1M"
4130 result:
4131 ssn: "str | redact(!perm.view_ssn)"
4132 employee_id: "str | mask(4)"
4133 internal_notes: "omit"
4134"#;
4135 let routes = compile_config(yaml).unwrap().routes;
4136 let route = routes.get("get_compensation").expect("missing route");
4137 assert_eq!(route.args.len(), 2);
4138 assert_eq!(route.result.len(), 3);
4139
4140 let ssn = route.result.iter().find(|f| f.field == "ssn").unwrap();
4142 assert_eq!(ssn.pipeline.stages.len(), 2);
4143 assert!(matches!(
4144 ssn.pipeline.stages[0],
4145 Stage::Type(TypeCheck::Str)
4146 ));
4147 assert!(matches!(
4148 ssn.pipeline.stages[1],
4149 Stage::Redact { condition: Some(_) }
4150 ));
4151
4152 let phases = route.declared_phases();
4154 assert!(phases.contains(crate::rules::Phase::Args));
4155 assert!(phases.contains(crate::rules::Phase::Result));
4156 }
4157
4158 #[test]
4159 fn compile_route_with_only_args_still_compiles() {
4160 let yaml = r#"
4163routes:
4164 validate_only:
4165 args:
4166 employee_id: "uuid"
4167"#;
4168 let routes = compile_config(yaml).unwrap().routes;
4169 assert!(routes.contains_key("validate_only"));
4170 }
4171
4172 #[test]
4173 fn compile_propagates_pipeline_parse_errors() {
4174 let yaml = r#"
4175routes:
4176 bad:
4177 result:
4178 x: "nonsense"
4179"#;
4180 let err = compile_config(yaml).unwrap_err();
4181 assert!(format!("{}", err).contains("unknown stage"));
4182 }
4183
4184 #[test]
4187 fn compile_captures_root_plugins_block_into_registry() {
4188 let yaml = r#"
4189plugins:
4190 - name: rate_limiter
4191 kind: native
4192 hooks: [tool_pre_invoke]
4193 capabilities: [read_subject]
4194 config:
4195 max_requests: 100
4196 - name: audit
4197 kind: native
4198 hooks: [tool_post_invoke]
4199routes:
4200 get_compensation:
4201 pre_invocation:
4202 - "plugin(rate_limiter)"
4203"#;
4204 let cfg = compile_config(yaml).unwrap();
4205 assert_eq!(cfg.plugins.len(), 2);
4206 let rl = cfg.plugins.get("rate_limiter").unwrap();
4207 assert_eq!(rl.kind, "native");
4208 assert_eq!(rl.hooks, vec!["tool_pre_invoke".to_string()]);
4209 assert_eq!(rl.capabilities, vec!["read_subject".to_string()]);
4210 assert!(cfg.routes.contains_key("get_compensation"));
4212 }
4213
4214 #[test]
4215 fn compile_captures_route_level_plugin_overrides() {
4216 let yaml = r#"
4217plugins:
4218 - name: rate_limiter
4219 kind: native
4220 hooks: [tool_pre_invoke]
4221 config:
4222 max_requests: 100
4223routes:
4224 hot_path:
4225 pre_invocation:
4226 - "plugin(rate_limiter)"
4227 plugins:
4228 rate_limiter:
4229 config:
4230 max_requests: 10
4231 on_error: ignore
4232"#;
4233 let cfg = compile_config(yaml).unwrap();
4234 let route = cfg.routes.get("hot_path").unwrap();
4235 let ovr = route.plugin_overrides.get("rate_limiter").unwrap();
4236 assert_eq!(ovr.on_error.as_deref(), Some("ignore"));
4237 let cfg_yaml = ovr.config.as_ref().unwrap();
4238 assert_eq!(
4239 cfg_yaml["max_requests"],
4240 serde_yaml::from_str::<serde_yaml::Value>("10").unwrap()
4241 );
4242
4243 let eff = crate::plugin_decl::EffectivePlugin::resolve(
4245 "rate_limiter",
4246 &cfg.plugins,
4247 &route.plugin_overrides,
4248 )
4249 .unwrap();
4250 assert_eq!(eff.on_error, Some("ignore"));
4251 assert_eq!(eff.hooks, &["tool_pre_invoke".to_string()]);
4253 }
4254
4255 #[test]
4258 fn compile_policy_block_value_parses_apl_body() {
4259 let yaml = r#"
4260pre_invocation:
4261 - "require(authenticated)"
4262result:
4263 ssn: "redact(!perm.view_ssn)"
4264"#;
4265 let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
4266 let compiled =
4267 compile_policy_block_value("global.policy.all", &value).expect("compile block");
4268 assert_eq!(compiled.route_key, "global.policy.all");
4269 assert_eq!(compiled.policy.len(), 1);
4270 assert_eq!(compiled.result.len(), 1);
4271 assert_eq!(compiled.result[0].field, "ssn");
4272 }
4273
4274 #[test]
4275 fn compile_policy_block_value_null_is_empty_route() {
4276 let value = serde_yaml::Value::Null;
4277 let compiled =
4278 compile_policy_block_value("global.defaults.tool", &value).expect("compile null");
4279 assert!(compiled.declared_phases().is_empty());
4280 assert_eq!(compiled.route_key, "global.defaults.tool");
4281 }
4282
4283 #[test]
4284 fn compile_policy_block_value_threads_source_into_rule_paths() {
4285 let yaml = r#"
4286pre_invocation:
4287 - "require(authenticated)"
4288"#;
4289 let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
4290 let compiled = compile_policy_block_value("global.policies.hr", &value).expect("compile");
4291 match &compiled.policy[0] {
4292 crate::rules::Effect::When { source, .. } => {
4293 assert_eq!(source, "global.policies.hr.pre_invocation[0]");
4294 },
4295 other => panic!("expected When, got {:?}", other),
4296 }
4297 }
4298
4299 #[test]
4302 fn parse_delegate_step_with_only_plugin() {
4303 let yaml = r#"
4304- delegate:
4305 plugin: workday-oauth
4306"#;
4307 let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
4308 let entry = &value.as_sequence().unwrap()[0];
4309 let step = parse_step(entry, "test.policy[0]").expect("parse");
4310 let crate::step::Step::Delegate(ds) = step else {
4311 panic!("expected Delegate, got {step:?}");
4312 };
4313 assert_eq!(ds.plugin_name, "workday-oauth");
4314 assert!(ds.config_override.is_none());
4315 assert!(ds.on_error.is_none());
4316 assert_eq!(ds.source, "test.policy[0]");
4317 }
4318
4319 #[test]
4320 fn parse_delegate_step_with_config_and_on_error() {
4321 let yaml = r#"
4322- delegate:
4323 plugin: workday-oauth
4324 config:
4325 target: workday-api
4326 permissions: [read_compensation]
4327 on_error: deny
4328"#;
4329 let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
4330 let entry = &value.as_sequence().unwrap()[0];
4331 let step = parse_step(entry, "test.policy[1]").expect("parse");
4332 let crate::step::Step::Delegate(ds) = step else {
4333 panic!("expected Delegate, got {step:?}");
4334 };
4335 assert_eq!(ds.plugin_name, "workday-oauth");
4336 assert_eq!(ds.on_error.as_deref(), Some("deny"));
4337 let cfg = ds.config_override.as_ref().expect("config_override set");
4338 let target = cfg
4339 .as_mapping()
4340 .and_then(|m| m.get(serde_yaml::Value::String("target".into())))
4341 .and_then(|v| v.as_str());
4342 assert_eq!(target, Some("workday-api"));
4343 }
4344
4345 #[test]
4346 fn parse_delegate_step_missing_plugin_errors() {
4347 let yaml = r#"
4348- delegate:
4349 config: { target: workday-api }
4350"#;
4351 let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
4352 let entry = &value.as_sequence().unwrap()[0];
4353 let err = parse_step(entry, "test.policy[0]").expect_err("missing plugin");
4354 let msg = format!("{err}");
4355 assert!(msg.contains("requires `plugin:"), "got: {msg}");
4356 }
4357
4358 #[test]
4359 fn parse_delegate_step_empty_plugin_errors() {
4360 let yaml = r#"
4361- delegate:
4362 plugin: ""
4363"#;
4364 let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
4365 let entry = &value.as_sequence().unwrap()[0];
4366 let err = parse_step(entry, "test.policy[0]").expect_err("empty plugin");
4367 let msg = format!("{err}");
4368 assert!(msg.contains("cannot be empty"), "got: {msg}");
4369 }
4370
4371 #[test]
4372 fn parse_delegate_step_non_string_on_error_errors() {
4373 let yaml = r#"
4374- delegate:
4375 plugin: workday-oauth
4376 on_error: 42
4377"#;
4378 let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
4379 let entry = &value.as_sequence().unwrap()[0];
4380 let err = parse_step(entry, "test.policy[0]").expect_err("non-string on_error");
4381 let msg = format!("{err}");
4382 assert!(msg.contains("on_error"), "got: {msg}");
4383 }
4384
4385 #[test]
4386 fn parse_delegate_step_non_map_body_errors() {
4387 let yaml = r#"
4388- delegate: workday-oauth
4389"#;
4390 let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
4391 let entry = &value.as_sequence().unwrap()[0];
4392 let err = parse_step(entry, "test.policy[0]").expect_err("non-map delegate body");
4393 let msg = format!("{err}");
4394 assert!(msg.contains("must be a map"), "got: {msg}");
4395 }
4396
4397 #[test]
4400 fn parse_delegate_string_bare_plugin_name() {
4401 let yaml = r#"- "delegate(workday-oauth)""#;
4402 let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
4403 let entry = &value.as_sequence().unwrap()[0];
4404 let step = parse_step(entry, "test.policy[0]").expect("parse");
4405 let crate::step::Step::Delegate(ds) = step else {
4406 panic!("expected Delegate, got {step:?}");
4407 };
4408 assert_eq!(ds.plugin_name, "workday-oauth");
4409 assert!(ds.config_override.is_none());
4410 assert!(ds.on_error.is_none());
4411 assert_eq!(ds.source, "test.policy[0]");
4412 }
4413
4414 #[test]
4415 fn parse_delegate_string_with_string_kwargs() {
4416 let yaml =
4417 r#"- "delegate(workday-oauth, target: workday-api, audience: https://workday.com)""#;
4418 let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
4419 let entry = &value.as_sequence().unwrap()[0];
4420 let step = parse_step(entry, "test.policy[0]").expect("parse");
4421 let crate::step::Step::Delegate(ds) = step else {
4422 panic!("expected Delegate, got {step:?}");
4423 };
4424 assert_eq!(ds.plugin_name, "workday-oauth");
4425 let cfg = ds.config_override.as_ref().unwrap().as_mapping().unwrap();
4426 assert_eq!(
4427 cfg.get(serde_yaml::Value::String("target".into()))
4428 .and_then(|v| v.as_str()),
4429 Some("workday-api"),
4430 );
4431 assert_eq!(
4432 cfg.get(serde_yaml::Value::String("audience".into()))
4433 .and_then(|v| v.as_str()),
4434 Some("https://workday.com"),
4435 );
4436 }
4437
4438 #[test]
4439 fn parse_delegate_string_with_list_kwarg() {
4440 let yaml = r#"- "delegate(workday-oauth, permissions: [read_compensation, write_notes])""#;
4441 let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
4442 let entry = &value.as_sequence().unwrap()[0];
4443 let step = parse_step(entry, "test.policy[0]").expect("parse");
4444 let crate::step::Step::Delegate(ds) = step else {
4445 panic!("expected Delegate");
4446 };
4447 let cfg = ds.config_override.as_ref().unwrap().as_mapping().unwrap();
4448 let perms = cfg
4449 .get(serde_yaml::Value::String("permissions".into()))
4450 .and_then(|v| v.as_sequence())
4451 .expect("permissions sequence");
4452 let names: Vec<&str> = perms.iter().filter_map(|v| v.as_str()).collect();
4453 assert_eq!(names, vec!["read_compensation", "write_notes"]);
4454 }
4455
4456 #[test]
4457 fn parse_delegate_string_on_error_pulled_out() {
4458 let yaml = r#"- "delegate(workday-oauth, target: workday-api, on_error: continue)""#;
4459 let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
4460 let entry = &value.as_sequence().unwrap()[0];
4461 let step = parse_step(entry, "test.policy[0]").expect("parse");
4462 let crate::step::Step::Delegate(ds) = step else {
4463 panic!("expected Delegate");
4464 };
4465 assert_eq!(ds.on_error.as_deref(), Some("continue"));
4466 let cfg = ds.config_override.as_ref().unwrap().as_mapping().unwrap();
4468 assert!(
4469 cfg.get(serde_yaml::Value::String("on_error".into()))
4470 .is_none(),
4471 "on_error must not appear in config_override"
4472 );
4473 }
4474
4475 #[test]
4476 fn parse_delegate_string_quoted_plugin_name() {
4477 let yaml = r#"- 'delegate("workday-oauth")'"#;
4481 let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
4482 let entry = &value.as_sequence().unwrap()[0];
4483 let step = parse_step(entry, "test.policy[0]").expect("parse");
4484 let crate::step::Step::Delegate(ds) = step else {
4485 panic!("expected Delegate");
4486 };
4487 assert_eq!(ds.plugin_name, "workday-oauth");
4488 }
4489
4490 #[test]
4491 fn parse_delegate_string_quoted_value_preserves_internal_commas() {
4492 let yaml =
4493 r#"- 'delegate(workday-oauth, audience: "https://workday.com,backup.workday.com")'"#;
4494 let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
4495 let entry = &value.as_sequence().unwrap()[0];
4496 let step = parse_step(entry, "test.policy[0]").expect("parse");
4497 let crate::step::Step::Delegate(ds) = step else {
4498 panic!("expected Delegate");
4499 };
4500 let cfg = ds.config_override.as_ref().unwrap().as_mapping().unwrap();
4501 assert_eq!(
4502 cfg.get(serde_yaml::Value::String("audience".into()))
4503 .and_then(|v| v.as_str()),
4504 Some("https://workday.com,backup.workday.com"),
4505 );
4506 }
4507
4508 #[test]
4509 fn parse_delegate_string_empty_args_errors() {
4510 let yaml = r#"- "delegate()""#;
4511 let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
4512 let entry = &value.as_sequence().unwrap()[0];
4513 let err = parse_step(entry, "test.policy[0]").expect_err("empty args");
4514 let msg = format!("{err}");
4515 assert!(msg.contains("plugin name"), "got: {msg}");
4516 }
4517
4518 #[test]
4519 fn parse_delegate_string_plugin_kwarg_rejected() {
4520 let yaml = r#"- "delegate(workday-oauth, plugin: other-thing)""#;
4523 let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
4524 let entry = &value.as_sequence().unwrap()[0];
4525 let err = parse_step(entry, "test.policy[0]").expect_err("plugin kwarg");
4526 let msg = format!("{err}");
4527 assert!(msg.contains("positional"), "got: {msg}");
4528 }
4529
4530 #[test]
4531 fn parse_delegate_string_kwarg_missing_colon_errors() {
4532 let yaml = r#"- "delegate(workday-oauth, target workday-api)""#;
4533 let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
4534 let entry = &value.as_sequence().unwrap()[0];
4535 let err = parse_step(entry, "test.policy[0]").expect_err("missing colon");
4536 let msg = format!("{err}");
4537 assert!(msg.contains("key: value"), "got: {msg}");
4538 }
4539
4540 #[test]
4541 fn parse_delegate_string_unbalanced_brackets_errors() {
4542 let yaml = r#"- "delegate(workday-oauth, permissions: [read_compensation)""#;
4543 let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
4544 let entry = &value.as_sequence().unwrap()[0];
4545 let err = parse_step(entry, "test.policy[0]").expect_err("unbalanced");
4546 let msg = format!("{err}");
4547 assert!(
4548 msg.contains("unmatched") || msg.contains("unbalanced"),
4549 "got: {msg}"
4550 );
4551 }
4552
4553 #[test]
4554 fn compile_route_mixed_string_and_map_delegate_forms() {
4555 let yaml = r#"
4558routes:
4559 get_compensation:
4560 pre_invocation:
4561 - "require(role.hr)"
4562 - "delegate(workday-oauth, target: workday-api, permissions: [read_compensation])"
4563 - delegate:
4564 plugin: audit-receipt
4565 on_error: continue
4566 config:
4567 mode: trace
4568"#;
4569 let cfg = compile_config(yaml).expect("compile");
4570 let route = cfg.routes.get("get_compensation").expect("route");
4571 assert_eq!(route.policy.len(), 3);
4572
4573 let crate::rules::Effect::Delegate(s1) = &route.policy[1] else {
4575 panic!("expected Delegate at policy[1]");
4576 };
4577 assert_eq!(s1.plugin_name, "workday-oauth");
4578 assert!(s1.on_error.is_none());
4579
4580 let crate::rules::Effect::Delegate(s2) = &route.policy[2] else {
4582 panic!("expected Delegate at policy[2]");
4583 };
4584 assert_eq!(s2.plugin_name, "audit-receipt");
4585 assert_eq!(s2.on_error.as_deref(), Some("continue"));
4586 }
4587
4588 #[test]
4589 fn compile_route_with_delegate_in_policy_and_post_policy() {
4590 let yaml = r#"
4594routes:
4595 get_compensation:
4596 pre_invocation:
4597 - "require(role.hr)"
4598 - delegate:
4599 plugin: workday-oauth
4600 config:
4601 target: workday-api
4602 permissions: [read_compensation]
4603 - "require(authenticated)"
4604 post_invocation:
4605 - delegate:
4606 plugin: audit-biscuit
4607 on_error: continue
4608"#;
4609 let cfg = compile_config(yaml).expect("compile");
4610 let route = cfg.routes.get("get_compensation").expect("route present");
4611 assert_eq!(route.policy.len(), 3);
4612
4613 let crate::rules::Effect::Delegate(ds) = &route.policy[1] else {
4615 panic!("expected Delegate at policy[1], got {:?}", route.policy[1]);
4616 };
4617 assert_eq!(ds.plugin_name, "workday-oauth");
4618 assert_eq!(ds.source, "get_compensation.pre_invocation[1]");
4619
4620 let crate::rules::Effect::Delegate(post_ds) = &route.post_policy[0] else {
4622 panic!("expected Delegate at post_policy[0]");
4623 };
4624 assert_eq!(post_ds.plugin_name, "audit-biscuit");
4625 assert_eq!(post_ds.on_error.as_deref(), Some("continue"));
4626 assert_eq!(post_ds.source, "get_compensation.post_invocation[0]");
4627 }
4628
4629 #[test]
4632 fn parse_pipeline_rejects_validate_stage_at_compile_time() {
4633 let err = parse_pipeline("str | validate(ssn_format) | mask(4)")
4638 .expect_err("validate(name) should fail to parse");
4639 let msg = format!("{err}");
4640 assert!(
4641 msg.contains("not implemented"),
4642 "diagnostic should explain that validate is unimplemented: {msg}",
4643 );
4644 assert!(
4645 msg.contains("regex") && msg.contains("plugin"),
4646 "diagnostic should suggest regex(...) and plugin(...): {msg}",
4647 );
4648 assert!(
4649 msg.contains("ssn_format"),
4650 "diagnostic should echo the rejected validator name: {msg}",
4651 );
4652 }
4653
4654 #[test]
4655 fn parse_pipeline_does_not_reject_other_stages() {
4656 let p = parse_pipeline("str | len(..100) | regex(\"^[A-Z]+$\") | mask(4)")
4659 .expect("non-validate pipeline parses");
4660 assert_eq!(p.stages.len(), 4);
4661 }
4662}