mirror of
https://github.com/microsoft/regorus.git
synced 2026-08-05 02:16:11 +00:00
feat: add Azure Policy constraint parser (#658)
Add constraint.rs module that parses Azure Policy JSON constraints into span-annotated AST nodes: - Logical combinators: allOf, anyOf, not - Leaf conditions: field/value with all 19 operators - Count blocks: field-count and value-count with where clauses Public API: parse_constraint() parses a standalone constraint from JSON. Includes YAML-driven test suite with 6 test files covering operators, fields, expressions, logical combinators, count, and parse errors.
This commit is contained in:
committed by
GitHub
parent
95bffcb5f9
commit
687be2850b
411
src/languages/azure_policy/parser/constraint.rs
Normal file
411
src/languages/azure_policy/parser/constraint.rs
Normal file
@@ -0,0 +1,411 @@
|
||||
// Copyright (c) Microsoft Corporation.
|
||||
// Licensed under the MIT License.
|
||||
|
||||
//! Constraint parsing: logical combinators, leaf conditions, and count blocks.
|
||||
|
||||
use alloc::boxed::Box;
|
||||
use alloc::string::String;
|
||||
use alloc::vec::Vec;
|
||||
|
||||
use crate::lexer::Span;
|
||||
|
||||
use crate::languages::azure_policy::ast::{
|
||||
Condition, Constraint, CountNode, FieldNode, JsonValue, Lhs, NameNode, OperatorNode,
|
||||
};
|
||||
|
||||
use super::classify_field;
|
||||
use super::core::{CountInner, EntryValue, Parser};
|
||||
use super::error::ParseError;
|
||||
use super::parse_operator_kind;
|
||||
|
||||
/// Set `slot` to `val`, returning a [`ParseError::DuplicateKey`] if it was already set.
|
||||
fn set_once<T>(slot: &mut Option<T>, val: T, key: &str, span: &Span) -> Result<(), ParseError> {
|
||||
if slot.is_some() {
|
||||
return Err(ParseError::DuplicateKey {
|
||||
span: span.clone(),
|
||||
key: String::from(key),
|
||||
});
|
||||
}
|
||||
*slot = Some(val);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl<'source> Parser<'source> {
|
||||
/// Parse a constraint (a JSON object: logical combinator or leaf condition).
|
||||
pub fn parse_constraint(&mut self) -> Result<Constraint, ParseError> {
|
||||
let open = self.expect_symbol("{")?;
|
||||
let mut entries: Vec<(Span, String, EntryValue)> = Vec::new();
|
||||
|
||||
if self.token_text() != "}" {
|
||||
loop {
|
||||
let (key_span, key) = self.expect_string()?;
|
||||
self.expect_symbol(":")?;
|
||||
let key_lower = key.to_lowercase();
|
||||
|
||||
let value = match key_lower.as_str() {
|
||||
"allof" | "anyof" => {
|
||||
if self.token_text() != "[" {
|
||||
return Err(ParseError::LogicalOperatorNotArray {
|
||||
span: key_span,
|
||||
operator: key,
|
||||
});
|
||||
}
|
||||
EntryValue::ConstraintArray(self.parse_constraint_array()?)
|
||||
}
|
||||
"not" => EntryValue::SingleConstraint(self.parse_constraint()?),
|
||||
"count" => EntryValue::CountInner(Box::new(self.parse_count_inner()?)),
|
||||
_ => EntryValue::Json(self.parse_json_value()?),
|
||||
};
|
||||
|
||||
// Detect duplicate keys during collection (case-insensitive).
|
||||
if entries
|
||||
.iter()
|
||||
.any(|entry| entry.1.eq_ignore_ascii_case(&key))
|
||||
{
|
||||
return Err(ParseError::DuplicateKey {
|
||||
span: key_span,
|
||||
key,
|
||||
});
|
||||
}
|
||||
|
||||
entries.push((key_span, key, value));
|
||||
|
||||
if self.token_text() == "," {
|
||||
self.advance()?;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let close = self.expect_symbol("}")?;
|
||||
let span = Span {
|
||||
source: open.source.clone(),
|
||||
line: open.line,
|
||||
col: open.col,
|
||||
start: open.start,
|
||||
end: close.end,
|
||||
};
|
||||
|
||||
Self::build_constraint(span, entries)
|
||||
}
|
||||
|
||||
/// Parse a `[constraint, constraint, ...]` array for `allOf`/`anyOf`.
|
||||
fn parse_constraint_array(&mut self) -> Result<Vec<Constraint>, ParseError> {
|
||||
self.expect_symbol("[")?;
|
||||
let mut constraints = Vec::new();
|
||||
if self.token_text() != "]" {
|
||||
constraints.push(self.parse_constraint()?);
|
||||
while self.token_text() == "," {
|
||||
self.advance()?;
|
||||
constraints.push(self.parse_constraint()?);
|
||||
}
|
||||
}
|
||||
self.expect_symbol("]")?;
|
||||
Ok(constraints)
|
||||
}
|
||||
|
||||
/// Dispatch on collected entries to build the appropriate constraint.
|
||||
fn build_constraint(
|
||||
span: Span,
|
||||
entries: Vec<(Span, String, EntryValue)>,
|
||||
) -> Result<Constraint, ParseError> {
|
||||
// Check for logical operators with extra keys.
|
||||
for entry in entries.iter() {
|
||||
let lower = entry.1.to_lowercase();
|
||||
if matches!(lower.as_str(), "allof" | "anyof" | "not") && entries.len() > 1 {
|
||||
return Err(ParseError::ExtraKeysInLogical {
|
||||
span: entry.0.clone(),
|
||||
operator: entry.1.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Single-entry logical operators.
|
||||
if entries.len() == 1 {
|
||||
// Safe: we just checked len() == 1.
|
||||
let mut entries = entries;
|
||||
let (key_span, key, value) = entries
|
||||
.pop()
|
||||
.ok_or_else(|| ParseError::MissingLhsOperand { span: span.clone() })?;
|
||||
match key.to_lowercase().as_str() {
|
||||
"allof" => {
|
||||
// Non-array values are caught during parsing (LogicalOperatorNotArray).
|
||||
let EntryValue::ConstraintArray(constraints) = value else {
|
||||
return Err(ParseError::LogicalOperatorNotArray {
|
||||
span: key_span,
|
||||
operator: key,
|
||||
});
|
||||
};
|
||||
return Ok(Constraint::AllOf { span, constraints });
|
||||
}
|
||||
"anyof" => {
|
||||
let EntryValue::ConstraintArray(constraints) = value else {
|
||||
return Err(ParseError::LogicalOperatorNotArray {
|
||||
span: key_span,
|
||||
operator: key,
|
||||
});
|
||||
};
|
||||
return Ok(Constraint::AnyOf { span, constraints });
|
||||
}
|
||||
"not" => {
|
||||
let EntryValue::SingleConstraint(constraint) = value else {
|
||||
// `not` is always parsed as SingleConstraint in parse_constraint,
|
||||
// so this branch is structurally unreachable.
|
||||
return Err(ParseError::UnexpectedToken {
|
||||
span: key_span,
|
||||
expected: "constraint for 'not'",
|
||||
});
|
||||
};
|
||||
return Ok(Constraint::Not {
|
||||
span,
|
||||
constraint: Box::new(constraint),
|
||||
});
|
||||
}
|
||||
_ => {
|
||||
return Self::build_condition(span, alloc::vec![(key_span, key, value)]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Self::build_condition(span, entries)
|
||||
}
|
||||
|
||||
/// Build a leaf condition from collected object entries.
|
||||
fn build_condition(
|
||||
span: Span,
|
||||
entries: Vec<(Span, String, EntryValue)>,
|
||||
) -> Result<Constraint, ParseError> {
|
||||
let mut field: Option<(Span, JsonValue)> = None;
|
||||
let mut value: Option<(Span, JsonValue)> = None;
|
||||
let mut count: Option<(Span, Box<CountInner>)> = None;
|
||||
let mut operator: Option<OperatorNode> = None;
|
||||
let mut rhs: Option<JsonValue> = None;
|
||||
|
||||
for (key_span, key, entry_value) in entries {
|
||||
match key.to_lowercase().as_str() {
|
||||
"field" => {
|
||||
let EntryValue::Json(jv) = entry_value else {
|
||||
return Err(ParseError::UnexpectedToken {
|
||||
span: key_span,
|
||||
expected: "JSON value for 'field'",
|
||||
});
|
||||
};
|
||||
set_once(&mut field, (key_span.clone(), jv), &key, &key_span)?;
|
||||
}
|
||||
"value" => {
|
||||
let EntryValue::Json(jv) = entry_value else {
|
||||
return Err(ParseError::UnexpectedToken {
|
||||
span: key_span,
|
||||
expected: "JSON value for 'value'",
|
||||
});
|
||||
};
|
||||
set_once(&mut value, (key_span.clone(), jv), &key, &key_span)?;
|
||||
}
|
||||
"count" => {
|
||||
let EntryValue::CountInner(ci) = entry_value else {
|
||||
return Err(ParseError::UnexpectedToken {
|
||||
span: key_span,
|
||||
expected: "object for 'count'",
|
||||
});
|
||||
};
|
||||
set_once(&mut count, (key_span.clone(), ci), &key, &key_span)?;
|
||||
}
|
||||
_ => {
|
||||
if let Some(op_kind) = parse_operator_kind(&key.to_lowercase()) {
|
||||
let EntryValue::Json(jv) = entry_value else {
|
||||
return Err(ParseError::UnexpectedToken {
|
||||
span: key_span,
|
||||
expected: "JSON value for operator",
|
||||
});
|
||||
};
|
||||
if operator.is_some() {
|
||||
return Err(ParseError::MultipleOperators { span: key_span });
|
||||
}
|
||||
operator = Some(OperatorNode {
|
||||
span: key_span,
|
||||
kind: op_kind,
|
||||
});
|
||||
rhs = Some(jv);
|
||||
} else {
|
||||
return Err(ParseError::UnrecognizedKey {
|
||||
span: key_span,
|
||||
key,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let operator =
|
||||
operator.ok_or_else(|| ParseError::MissingOperator { span: span.clone() })?;
|
||||
let rhs_json = rhs.ok_or_else(|| ParseError::MissingOperator { span: span.clone() })?;
|
||||
let rhs_value = Self::json_to_value_or_expr(rhs_json)?;
|
||||
|
||||
let lhs = match (field, value, count) {
|
||||
(Some((_, fv)), None, None) => Lhs::Field(Self::json_to_field(fv)?),
|
||||
(None, Some((key_span, vv)), None) => Lhs::Value {
|
||||
key_span,
|
||||
value: Self::json_to_value_or_expr(vv)?,
|
||||
},
|
||||
(None, None, Some((_, ci))) => Lhs::Count(Self::finalize_count(*ci)?),
|
||||
|
||||
(None, None, None) => {
|
||||
return Err(ParseError::MissingLhsOperand { span: span.clone() });
|
||||
}
|
||||
_ => {
|
||||
return Err(ParseError::MultipleLhsOperands { span: span.clone() });
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Constraint::Condition(Box::new(Condition {
|
||||
span,
|
||||
lhs,
|
||||
operator,
|
||||
rhs: rhs_value,
|
||||
})))
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Count parsing
|
||||
// ========================================================================
|
||||
|
||||
/// Parse the inner object of a `"count": { ... }` block.
|
||||
pub fn parse_count_inner(&mut self) -> Result<CountInner, ParseError> {
|
||||
let open = self.expect_symbol("{")?;
|
||||
|
||||
let mut field: Option<(Span, JsonValue)> = None;
|
||||
let mut value: Option<(Span, JsonValue)> = None;
|
||||
let mut name: Option<(Span, JsonValue)> = None;
|
||||
let mut where_: Option<Constraint> = None;
|
||||
|
||||
if self.token_text() != "}" {
|
||||
loop {
|
||||
let (key_span, key) = self.expect_string()?;
|
||||
self.expect_symbol(":")?;
|
||||
let key_lower = key.to_lowercase();
|
||||
|
||||
match key_lower.as_str() {
|
||||
"field" => {
|
||||
let jv = self.parse_json_value()?;
|
||||
set_once(&mut field, (key_span.clone(), jv), &key_lower, &key_span)?;
|
||||
}
|
||||
"value" => {
|
||||
let jv = self.parse_json_value()?;
|
||||
set_once(&mut value, (key_span.clone(), jv), &key_lower, &key_span)?;
|
||||
}
|
||||
"name" => {
|
||||
let jv = self.parse_json_value()?;
|
||||
set_once(&mut name, (key_span.clone(), jv), &key_lower, &key_span)?;
|
||||
}
|
||||
"where" => {
|
||||
let c = self.parse_constraint()?;
|
||||
set_once(&mut where_, c, &key_lower, &key_span)?;
|
||||
}
|
||||
_ => {
|
||||
return Err(ParseError::UnrecognizedKey {
|
||||
span: key_span,
|
||||
key: key_lower,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if self.token_text() == "," {
|
||||
self.advance()?;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let close = self.expect_symbol("}")?;
|
||||
let span = Span {
|
||||
source: open.source.clone(),
|
||||
line: open.line,
|
||||
col: open.col,
|
||||
start: open.start,
|
||||
end: close.end,
|
||||
};
|
||||
|
||||
Ok(CountInner {
|
||||
span,
|
||||
field,
|
||||
value,
|
||||
name,
|
||||
where_,
|
||||
})
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Helpers
|
||||
// ========================================================================
|
||||
|
||||
/// Convert a JSON value (expected to be a string) into a [`FieldNode`].
|
||||
pub fn json_to_field(jv: JsonValue) -> Result<FieldNode, ParseError> {
|
||||
let (span, text) = match jv {
|
||||
JsonValue::Str(span, text) => (span, text),
|
||||
other => {
|
||||
return Err(ParseError::UnexpectedToken {
|
||||
span: other.span().clone(),
|
||||
expected: "string for 'field' value",
|
||||
});
|
||||
}
|
||||
};
|
||||
let kind = classify_field(&text, &span)?;
|
||||
Ok(FieldNode { span, kind })
|
||||
}
|
||||
|
||||
/// Finalize a [`CountInner`] into a [`CountNode`].
|
||||
pub fn finalize_count(ci: CountInner) -> Result<CountNode, ParseError> {
|
||||
let CountInner {
|
||||
span,
|
||||
field,
|
||||
value,
|
||||
name,
|
||||
where_,
|
||||
} = ci;
|
||||
let where_box = where_.map(Box::new);
|
||||
|
||||
let name_node = match name {
|
||||
Some((key_span, jv)) => {
|
||||
if value.is_none() {
|
||||
return Err(ParseError::MisplacedCountName { span: key_span });
|
||||
}
|
||||
match jv {
|
||||
JsonValue::Str(name_span, text) => Some(NameNode {
|
||||
span: name_span,
|
||||
name: text,
|
||||
}),
|
||||
_ => {
|
||||
return Err(ParseError::InvalidCountName {
|
||||
span: jv.span().clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
|
||||
match (field, value) {
|
||||
(None, None) => Err(ParseError::MissingCountCollection { span }),
|
||||
(Some((_, fv)), None) => {
|
||||
let field_node = Self::json_to_field(fv)?;
|
||||
Ok(CountNode::Field {
|
||||
span,
|
||||
field: field_node,
|
||||
where_: where_box,
|
||||
})
|
||||
}
|
||||
(None, Some((_, vv))) => {
|
||||
let val = Self::json_to_value_or_expr(vv)?;
|
||||
Ok(CountNode::Value {
|
||||
span,
|
||||
value: val,
|
||||
name: name_node,
|
||||
where_: where_box,
|
||||
})
|
||||
}
|
||||
(Some(_), Some(_)) => Err(ParseError::MultipleCountCollections { span }),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,10 @@ pub enum ParseError {
|
||||
InvalidCountName { span: Span },
|
||||
/// `name` used without `value` in count.
|
||||
MisplacedCountName { span: Span },
|
||||
/// Multiple operator keys in a single condition.
|
||||
MultipleOperators { span: Span },
|
||||
/// A duplicate key was found in a JSON object.
|
||||
DuplicateKey { span: Span, key: String },
|
||||
/// A custom error message (e.g., from sub-parsing).
|
||||
Custom { span: Span, message: String },
|
||||
}
|
||||
@@ -137,6 +141,16 @@ impl core::fmt::Display for ParseError {
|
||||
span.error("'name' can only be used with count-value")
|
||||
)
|
||||
}
|
||||
ParseError::MultipleOperators { ref span } => {
|
||||
write!(
|
||||
f,
|
||||
"{}",
|
||||
span.error("only one operator key allowed in a condition")
|
||||
)
|
||||
}
|
||||
ParseError::DuplicateKey { ref span, ref key } => {
|
||||
write!(f, "{}", span.error(&format!("duplicate key \"{}\"", key)))
|
||||
}
|
||||
ParseError::Custom {
|
||||
ref span,
|
||||
ref message,
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
// Copyright (c) Microsoft Corporation.
|
||||
// Licensed under the MIT License.
|
||||
|
||||
//! Core recursive-descent JSON parser for Azure Policy.
|
||||
//! Recursive-descent JSON parser for Azure Policy rule constraints.
|
||||
//!
|
||||
//! Provides the low-level token-driven parser (`core::Parser`) that reads JSON from
|
||||
//! [`Lexer`] tokens, building span-annotated AST values in a single pass.
|
||||
//! No intermediate `serde_json::Value` is created.
|
||||
//! Parses Azure Policy JSON directly from [`Lexer`] tokens, building span-annotated
|
||||
//! AST nodes in a single pass. No intermediate `serde_json::Value` is created.
|
||||
//!
|
||||
//! Higher-level policy-aware parsing (constraints, policy rules, policy
|
||||
//! definitions) is layered on top by sibling modules.
|
||||
//! The parser is policy-aware: when parsing JSON objects, it dispatches on key names
|
||||
//! (`allOf`, `anyOf`, `not`, `field`, `value`, `count`, operator names) to build
|
||||
//! the appropriate AST nodes.
|
||||
|
||||
// Parser internals are consumed by constraint/policy_rule/policy_definition
|
||||
// modules added in a subsequent PR.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) mod core;
|
||||
mod constraint;
|
||||
mod core;
|
||||
mod error;
|
||||
|
||||
pub(super) use self::core::json_unescape;
|
||||
@@ -21,15 +19,42 @@ pub use error::ParseError;
|
||||
|
||||
use alloc::string::ToString as _;
|
||||
|
||||
use super::ast::{FieldKind, OperatorKind};
|
||||
use crate::lexer::{Source, TokenKind};
|
||||
|
||||
use super::ast::{Constraint, FieldKind, OperatorKind};
|
||||
use super::expr::ExprParser;
|
||||
|
||||
use self::core::Parser;
|
||||
|
||||
// ============================================================================
|
||||
// Helper functions (used by constraint/policy_rule/policy_definition modules)
|
||||
// Public API
|
||||
// ============================================================================
|
||||
|
||||
/// Parse a standalone constraint from a JSON source.
|
||||
///
|
||||
/// A constraint is one of:
|
||||
/// - Logical combinator: `{ "allOf": [...] }`, `{ "anyOf": [...] }`, `{ "not": {...} }`
|
||||
/// - Leaf condition: `{ "field": "...", "equals": "..." }`
|
||||
/// - Count condition: `{ "count": { "field": "..." }, "greater": 0 }`
|
||||
pub fn parse_constraint(source: &Source) -> Result<Constraint, ParseError> {
|
||||
let mut parser = Parser::new(source)?;
|
||||
let constraint = parser.parse_constraint()?;
|
||||
|
||||
if parser.tok.0 != TokenKind::Eof {
|
||||
return Err(ParseError::UnexpectedToken {
|
||||
span: parser.tok.1.clone(),
|
||||
expected: "end of input",
|
||||
});
|
||||
}
|
||||
|
||||
Ok(constraint)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helper functions (used across submodules)
|
||||
// ============================================================================
|
||||
|
||||
/// Check if a string is an ARM template expression (`[...]` but not `[[...`).
|
||||
#[allow(dead_code)]
|
||||
pub(super) fn is_template_expr(s: &str) -> bool {
|
||||
s.starts_with('[') && s.ends_with(']') && !s.starts_with("[[")
|
||||
}
|
||||
@@ -51,7 +76,6 @@ fn unwrap(s: &str, prefix_len: usize, suffix_len: usize) -> &str {
|
||||
}
|
||||
|
||||
/// Classify a field string into a [`FieldKind`].
|
||||
#[allow(dead_code)]
|
||||
pub(super) fn classify_field(
|
||||
text: &str,
|
||||
span: &crate::lexer::Span,
|
||||
@@ -96,7 +120,6 @@ pub(super) fn classify_field(
|
||||
}
|
||||
|
||||
/// Try to parse a lowercase key as an operator kind.
|
||||
#[allow(dead_code)]
|
||||
pub(super) fn parse_operator_kind(key: &str) -> Option<OperatorKind> {
|
||||
match key {
|
||||
"contains" => Some(OperatorKind::Contains),
|
||||
|
||||
Reference in New Issue
Block a user