mirror of
https://github.com/microsoft/regorus.git
synced 2026-08-05 02:16:11 +00:00
Adds the YAML test runner that exercises the companion test data PRs, plus several compiler fixes surfaced during testing: - Removed parameter register caching that produced wrong results inside short-circuiting allOf/anyOf blocks; added literal-index caching for parameter defaults to avoid repeated O(n) literal-table scans - Simplified cross-resource effect details to only emit roleDefinitionIds and type (deployment templates are not evaluated for compliance) - Replaced guid/uniqueString builtins with clear "unsupported" errors - Normalized datetime output to ISO 8601 with Z suffix - Added azure_policy parser MAX_COL constant (8192) for long template expressions, keeping the global DEFAULT_MAX_COL at 1024 - Added rvm to azure_policy feature dependencies since the compiler targets RVM bytecode Also restructures the example binary into examples/regorus/ with new azure-policy-eval and azure-policy-aliases subcommands, adds C# alias normalization tests, and documents Azure Policy support in the README. Signed-off-by: Anand Krishnamoorthi <anakrish@microsoft.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
305 lines
11 KiB
Rust
305 lines
11 KiB
Rust
// Copyright (c) Microsoft Corporation.
|
|
// Licensed under the MIT License.
|
|
|
|
//! Custom recursive-descent JSON parser for Azure Policy rules.
|
|
//!
|
|
//! Parses Azure Policy JSON directly from [`Lexer`] tokens, building span-annotated
|
|
//! AST nodes in a single pass. No intermediate `serde_json::Value` is created.
|
|
//!
|
|
//! 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.
|
|
//!
|
|
//! ## Usage
|
|
//!
|
|
//! ```ignore
|
|
//! use regorus::Source;
|
|
//! use regorus::languages::azure_policy::parser;
|
|
//!
|
|
//! let json = r#"{ "if": { "field": "type", "equals": "Microsoft.Compute/virtualMachines" },
|
|
//! "then": { "effect": "deny" } }"#;
|
|
//! let source = Source::from_contents("policy.json".into(), json.into())?;
|
|
//! let rule = parser::parse_policy_rule(&source)?;
|
|
//! ```
|
|
|
|
mod constraint;
|
|
mod core;
|
|
mod error;
|
|
mod policy_definition;
|
|
mod policy_rule;
|
|
|
|
pub(super) use self::core::json_unescape;
|
|
|
|
pub use error::ParseError;
|
|
|
|
use alloc::string::ToString as _;
|
|
|
|
use ::core::num::NonZeroU32;
|
|
|
|
use crate::lexer::{Source, TokenKind};
|
|
|
|
use super::ast::{Constraint, FieldKind, OperatorKind, PolicyDefinition, PolicyRule};
|
|
use super::expr::ExprParser;
|
|
|
|
use self::core::Parser;
|
|
|
|
/// Column-width limit for Azure Policy definitions.
|
|
///
|
|
/// Azure Policy definitions are often serialized as single-line JSON with
|
|
/// deeply nested template expressions, requiring a much higher limit than
|
|
/// the standard Rego default (1024).
|
|
pub const MAX_COL: u32 = Parser::MAX_COL;
|
|
|
|
// ============================================================================
|
|
// Public API
|
|
// ============================================================================
|
|
|
|
/// Parse an Azure Policy rule from a JSON source.
|
|
///
|
|
/// The source should contain a complete `policyRule` JSON object:
|
|
/// ```json
|
|
/// {
|
|
/// "if": { "field": "type", "equals": "Microsoft.Compute/virtualMachines" },
|
|
/// "then": { "effect": "deny" }
|
|
/// }
|
|
/// ```
|
|
///
|
|
/// Returns a span-annotated [`PolicyRule`] AST.
|
|
pub fn parse_policy_rule(source: &Source) -> Result<PolicyRule, ParseError> {
|
|
parse_policy_rule_with_max_col(source, None)
|
|
}
|
|
|
|
/// Like [`parse_policy_rule`] but with an explicit column-width override.
|
|
///
|
|
/// When `max_col` is `None`, uses [`MAX_COL`] (the Azure Policy default).
|
|
pub fn parse_policy_rule_with_max_col(
|
|
source: &Source,
|
|
max_col: Option<NonZeroU32>,
|
|
) -> Result<PolicyRule, ParseError> {
|
|
let mut parser = Parser::new_with_max_col(source, max_col.or(NonZeroU32::new(MAX_COL)))?;
|
|
let rule = parser.parse_policy_rule()?;
|
|
|
|
if parser.tok.0 != TokenKind::Eof {
|
|
return Err(ParseError::UnexpectedToken {
|
|
span: parser.tok.1.clone(),
|
|
expected: "end of input",
|
|
});
|
|
}
|
|
|
|
Ok(rule)
|
|
}
|
|
|
|
/// Parse a full Azure Policy definition from a JSON source.
|
|
///
|
|
/// Accepts two forms:
|
|
/// 1. **Wrapped**: `{ "properties": { "policyRule": ..., ... }, "id": ..., ... }`
|
|
/// 2. **Unwrapped**: `{ "displayName": ..., "policyRule": ..., ... }`
|
|
///
|
|
/// Returns a [`PolicyDefinition`] with typed fields for known properties
|
|
/// and a catch-all list of `extra` entries for everything else.
|
|
pub fn parse_policy_definition(source: &Source) -> Result<PolicyDefinition, ParseError> {
|
|
parse_policy_definition_with_max_col(source, None)
|
|
}
|
|
|
|
/// Like [`parse_policy_definition`] but with an explicit column-width override.
|
|
///
|
|
/// When `max_col` is `None`, uses [`MAX_COL`] (the Azure Policy default).
|
|
pub fn parse_policy_definition_with_max_col(
|
|
source: &Source,
|
|
max_col: Option<NonZeroU32>,
|
|
) -> Result<PolicyDefinition, ParseError> {
|
|
let mut parser = Parser::new_with_max_col(source, max_col.or(NonZeroU32::new(MAX_COL)))?;
|
|
let defn = parser.parse_policy_definition()?;
|
|
|
|
if parser.tok.0 != TokenKind::Eof {
|
|
return Err(ParseError::UnexpectedToken {
|
|
span: parser.tok.1.clone(),
|
|
expected: "end of input",
|
|
});
|
|
}
|
|
|
|
Ok(defn)
|
|
}
|
|
|
|
/// Parse a standalone constraint from a JSON source.
|
|
///
|
|
/// Useful for parsing just the `"if"` part of a policy rule.
|
|
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 `[[...`).
|
|
pub(super) fn is_template_expr(s: &str) -> bool {
|
|
s.starts_with('[') && s.ends_with(']') && !s.starts_with("[[")
|
|
}
|
|
|
|
/// Checked `s[prefix_len..]`.
|
|
///
|
|
/// Callers guarantee bounds via a prior `starts_with` check on an ASCII
|
|
/// prefix whose byte-length equals `prefix_len`.
|
|
fn tail(s: &str, prefix_len: usize) -> &str {
|
|
s.get(prefix_len..).unwrap_or_default()
|
|
}
|
|
|
|
/// Checked `s[prefix_len .. s.len() - suffix_len]`.
|
|
///
|
|
/// Callers guarantee bounds via prior `starts_with` / `ends_with` checks.
|
|
fn unwrap(s: &str, prefix_len: usize, suffix_len: usize) -> &str {
|
|
let end = s.len().saturating_sub(suffix_len);
|
|
s.get(prefix_len..end).unwrap_or_default()
|
|
}
|
|
|
|
/// Classify a field string into a [`FieldKind`].
|
|
pub(super) fn classify_field(
|
|
text: &str,
|
|
span: &crate::lexer::Span,
|
|
) -> Result<FieldKind, ParseError> {
|
|
let lower = text.to_lowercase();
|
|
match lower.as_str() {
|
|
"type" => Ok(FieldKind::Type),
|
|
"id" => Ok(FieldKind::Id),
|
|
"kind" => Ok(FieldKind::Kind),
|
|
"name" => Ok(FieldKind::Name),
|
|
"location" => Ok(FieldKind::Location),
|
|
"fullname" => Ok(FieldKind::FullName),
|
|
"tags" => Ok(FieldKind::Tags),
|
|
"identity.type" => Ok(FieldKind::IdentityType),
|
|
_ if lower.starts_with("identity.") => Ok(FieldKind::IdentityField(
|
|
tail(text, "identity.".len()).into(),
|
|
)),
|
|
"apiversion" => Ok(FieldKind::ApiVersion),
|
|
_ if lower.starts_with("tags.") => Ok(FieldKind::Tag(tail(text, "tags.".len()).into())),
|
|
_ if lower.starts_with("tags['") && text.ends_with("']") => Ok(FieldKind::Tag(
|
|
unwrap(text, "tags['".len(), "']".len()).into(),
|
|
)),
|
|
// Tags[tagName] — bracket notation without quotes.
|
|
// Tag name is everything between the first '[' and the LAST ']'.
|
|
// e.g. Tags[Dept.Name]] → tag name "Dept.Name]"
|
|
// Exclude tags[*] which is a wildcard iteration, not a tag name.
|
|
_ if lower.starts_with("tags[") && text.ends_with(']') && !text.contains("[*]") => Ok(
|
|
FieldKind::Tag(unwrap(text, "tags[".len(), "]".len()).into()),
|
|
),
|
|
_ if is_template_expr(text) => {
|
|
let inner = unwrap(text, 1, 1);
|
|
let expr = ExprParser::parse_from_brackets(inner, span).map_err(|e| {
|
|
ParseError::ExprParse {
|
|
span: span.clone(),
|
|
message: e.to_string(),
|
|
}
|
|
})?;
|
|
Ok(FieldKind::Expr(expr))
|
|
}
|
|
_ => Ok(FieldKind::Alias(text.into())),
|
|
}
|
|
}
|
|
|
|
/// Try to parse a lowercase key as an operator kind.
|
|
pub(super) fn parse_operator_kind(key: &str) -> Option<OperatorKind> {
|
|
match key {
|
|
"contains" => Some(OperatorKind::Contains),
|
|
"containskey" => Some(OperatorKind::ContainsKey),
|
|
"equals" => Some(OperatorKind::Equals),
|
|
"greater" => Some(OperatorKind::Greater),
|
|
"greaterorequals" => Some(OperatorKind::GreaterOrEquals),
|
|
"exists" => Some(OperatorKind::Exists),
|
|
"in" => Some(OperatorKind::In),
|
|
"less" => Some(OperatorKind::Less),
|
|
"lessorequals" => Some(OperatorKind::LessOrEquals),
|
|
"like" => Some(OperatorKind::Like),
|
|
"match" => Some(OperatorKind::Match),
|
|
"matchinsensitively" => Some(OperatorKind::MatchInsensitively),
|
|
"notcontains" => Some(OperatorKind::NotContains),
|
|
"notcontainskey" => Some(OperatorKind::NotContainsKey),
|
|
"notequals" => Some(OperatorKind::NotEquals),
|
|
"notin" => Some(OperatorKind::NotIn),
|
|
"notlike" => Some(OperatorKind::NotLike),
|
|
"notmatch" => Some(OperatorKind::NotMatch),
|
|
"notmatchinsensitively" => Some(OperatorKind::NotMatchInsensitively),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn dummy_span() -> crate::lexer::Span {
|
|
let source =
|
|
crate::lexer::Source::from_contents("<test>".into(), alloc::string::String::new())
|
|
.unwrap();
|
|
crate::lexer::Span {
|
|
source,
|
|
line: 1,
|
|
col: 1,
|
|
start: 0,
|
|
end: 0,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_is_template_expr() {
|
|
assert!(is_template_expr("[field('type')]"));
|
|
assert!(is_template_expr("[concat('a', 'b')]"));
|
|
assert!(!is_template_expr("[[escaped]"));
|
|
assert!(!is_template_expr("not-an-expr"));
|
|
assert!(!is_template_expr("[no-end-bracket"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_classify_field_builtin_names() {
|
|
let span = dummy_span();
|
|
assert!(matches!(classify_field("type", &span), Ok(FieldKind::Type)));
|
|
assert!(matches!(classify_field("Type", &span), Ok(FieldKind::Type)));
|
|
assert!(matches!(classify_field("name", &span), Ok(FieldKind::Name)));
|
|
assert!(matches!(
|
|
classify_field("location", &span),
|
|
Ok(FieldKind::Location)
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn test_classify_field_alias() {
|
|
let span = dummy_span();
|
|
match classify_field("Microsoft.Compute/virtualMachines/storageProfile", &span) {
|
|
Ok(FieldKind::Alias(a)) => {
|
|
assert_eq!(a, "Microsoft.Compute/virtualMachines/storageProfile");
|
|
}
|
|
other => panic!("expected Alias, got {:?}", other),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_classify_field_expression() {
|
|
let span = dummy_span();
|
|
match classify_field("[field('type')]", &span) {
|
|
Ok(FieldKind::Expr(_)) => {}
|
|
other => panic!("expected Expr, got {:?}", other),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_classify_field_double_bracket_not_expr() {
|
|
// [[escaped] should NOT be parsed as an expression.
|
|
let span = dummy_span();
|
|
match classify_field("[[escaped]", &span) {
|
|
Ok(FieldKind::Alias(a)) => assert_eq!(a, "[[escaped]"),
|
|
other => panic!("expected Alias, got {:?}", other),
|
|
}
|
|
}
|
|
}
|