feat(azure_policy): test runner, compiler fixes, and example program (#700)

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>
This commit is contained in:
Anand Krishnamoorthi
2026-04-30 13:02:37 -05:00
committed by GitHub
parent 7f42115b63
commit 4c92fb4d92
24 changed files with 1555 additions and 191 deletions

View File

@@ -37,84 +37,60 @@ pub(super) fn register(m: &mut builtins::BuiltinsMap<&'static str, builtins::Bui
// ── ISO 8601 datetime parsing ─────────────────────────────────────────
/// Parse an ISO 8601 / RFC 3339 datetime string.
///
/// Accepts multiple formats common in Azure Policy and ARM templates:
/// - RFC 3339 with `T` separator (`2024-01-15T12:00:00Z`, `...+05:30`)
/// - ISO 8601 without timezone (assumed UTC)
/// - Space-separated variants (`2024-01-15 12:00:00Z`)
fn parse_datetime(s: &str) -> Option<DateTime<FixedOffset>> {
parse_datetime_styled(s).map(|(dt, _)| dt)
}
/// The detected format style of a parsed datetime string, used to reproduce
/// the same shape when no explicit output format is given.
#[derive(Clone, Copy)]
enum DateTimeStyle {
/// RFC 3339 with T separator and Z suffix.
Rfc3339Z,
/// RFC 3339 with T separator and explicit numeric offset.
Rfc3339Offset,
/// T separator, no timezone (assumed UTC).
IsoNoTz,
/// Space separator, no timezone (assumed UTC).
SpaceNoTz,
/// Space separator with Z suffix.
SpaceZ,
/// Space separator with explicit offset.
SpaceOffset,
}
/// Parse a datetime string and return both the parsed value and the detected
/// input style so that output formatting can preserve it.
fn parse_datetime_styled(s: &str) -> Option<(DateTime<FixedOffset>, DateTimeStyle)> {
// Check for space separator at position 10 (after "YYYY-MM-DD") so that
// space-separated inputs are detected before RFC 3339 (which also allows
// a space in place of T).
if s.len() > 10 && s.as_bytes().get(10).copied() == Some(b' ') {
// Space separator with explicit offset (e.g. "2020-04-07 14:55:59+00:00").
if let Ok(dt) = DateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S%:z") {
return Some((dt, DateTimeStyle::SpaceOffset));
return Some(dt);
}
if let Ok(dt) = DateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S%.f%:z") {
return Some((dt, DateTimeStyle::SpaceOffset));
return Some(dt);
}
// Space separator with Z suffix (e.g. "2020-04-07 14:55:59Z").
if let Some(stripped) = s.strip_suffix('Z').or_else(|| s.strip_suffix('z')) {
if let Ok(naive) = chrono::NaiveDateTime::parse_from_str(stripped, "%Y-%m-%d %H:%M:%S")
{
let utc = DateTime::<Utc>::from_naive_utc_and_offset(naive, Utc);
return Some((utc.fixed_offset(), DateTimeStyle::SpaceZ));
return Some(utc.fixed_offset());
}
if let Ok(naive) =
chrono::NaiveDateTime::parse_from_str(stripped, "%Y-%m-%d %H:%M:%S%.f")
{
let utc = DateTime::<Utc>::from_naive_utc_and_offset(naive, Utc);
return Some((utc.fixed_offset(), DateTimeStyle::SpaceZ));
return Some(utc.fixed_offset());
}
}
// Space separator, no timezone (assume UTC).
if let Ok(naive) = chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S") {
let utc = DateTime::<Utc>::from_naive_utc_and_offset(naive, Utc);
return Some((utc.fixed_offset(), DateTimeStyle::SpaceNoTz));
return Some(utc.fixed_offset());
}
if let Ok(naive) = chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S%.f") {
let utc = DateTime::<Utc>::from_naive_utc_and_offset(naive, Utc);
return Some((utc.fixed_offset(), DateTimeStyle::SpaceNoTz));
return Some(utc.fixed_offset());
}
}
// Try RFC 3339 first (most common for ARM templates).
if let Ok(dt) = DateTime::parse_from_rfc3339(s) {
let style = if s.ends_with('Z') || s.ends_with('z') {
DateTimeStyle::Rfc3339Z
} else {
DateTimeStyle::Rfc3339Offset
};
return Some((dt, style));
return Some(dt);
}
// Try with T separator, no timezone (assume UTC).
if let Ok(naive) = chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S") {
let utc = DateTime::<Utc>::from_naive_utc_and_offset(naive, Utc);
return Some((utc.fixed_offset(), DateTimeStyle::IsoNoTz));
return Some(utc.fixed_offset());
}
if let Ok(naive) = chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S%.f") {
let utc = DateTime::<Utc>::from_naive_utc_and_offset(naive, Utc);
return Some((utc.fixed_offset(), DateTimeStyle::IsoNoTz));
return Some(utc.fixed_offset());
}
None
}
@@ -124,25 +100,13 @@ fn parse_datetime_styled(s: &str) -> Option<(DateTime<FixedOffset>, DateTimeStyl
/// explicit offset. Fractional seconds are included when non-zero.
fn format_datetime(dt: &DateTime<FixedOffset>) -> String {
if dt.offset().local_minus_utc() == 0 {
// UTC → use Z suffix
// UTC → use Z suffix. `%.f` includes subsecond digits only when non-zero.
dt.format("%Y-%m-%dT%H:%M:%S%.fZ").to_string()
} else {
dt.format("%Y-%m-%dT%H:%M:%S%.f%:z").to_string()
}
}
/// Format a datetime preserving the detected input style.
fn format_datetime_styled(dt: &DateTime<FixedOffset>, style: DateTimeStyle) -> String {
match style {
DateTimeStyle::Rfc3339Z => dt.format("%Y-%m-%dT%H:%M:%S%.fZ").to_string(),
DateTimeStyle::Rfc3339Offset => dt.format("%Y-%m-%dT%H:%M:%S%.f%:z").to_string(),
DateTimeStyle::IsoNoTz => dt.format("%Y-%m-%dT%H:%M:%S%.f").to_string(),
DateTimeStyle::SpaceNoTz => dt.format("%Y-%m-%d %H:%M:%S%.f").to_string(),
DateTimeStyle::SpaceZ => dt.format("%Y-%m-%d %H:%M:%S%.fZ").to_string(),
DateTimeStyle::SpaceOffset => dt.format("%Y-%m-%d %H:%M:%S%.f%:z").to_string(),
}
}
// ── ISO 8601 duration parsing ─────────────────────────────────────────
/// Parse an ISO 8601 duration string into a `chrono::Duration`.
@@ -230,7 +194,9 @@ fn parse_iso8601_duration(s: &str) -> Option<Duration> {
///
/// ARM template: `dateTimeAdd('2020-04-07 14:55:59', 'P3Y2M', 'yyyy-MM-dd')`
/// The optional third argument is a .NET-style custom date/time format string.
/// When absent, the output uses the same format as the input base string.
/// When absent, the output is normalized to ISO 8601 with T separator and
/// timezone; UTC/zero-offset values are emitted with a `Z` suffix (e.g.
/// `2023-06-07T14:55:59Z`).
fn fn_date_time_add(
_span: &Span,
_params: &[Ref<Expr>],
@@ -244,7 +210,7 @@ fn fn_date_time_add(
return Ok(Value::Undefined);
};
let Some((base_dt, style)) = parse_datetime_styled(base_str) else {
let Some(base_dt) = parse_datetime(base_str) else {
return Ok(Value::Undefined);
};
let Some(duration) = parse_iso8601_duration(duration_str) else {
@@ -257,7 +223,7 @@ fn fn_date_time_add(
let output = match args.get(2).and_then(as_str) {
Some(fmt) => format_datetime_dotnet(&result, fmt)?,
None => format_datetime_styled(&result, style),
None => format_datetime(&result),
};
Ok(Value::from(output))
}

View File

@@ -28,9 +28,9 @@ pub(super) fn register(m: &mut builtins::BuiltinsMap<&'static str, builtins::Bui
"azure.policy.fn.try_index_from_end",
(fn_try_index_from_end, 2),
);
// TODO: implement guid() and uniqueString() — need a SHA-2 based
// deterministic hash (FNV-1a could be used as a lighter alternative
// since these functions don't serve a security purpose).
// guid() and uniqueString() are not yet implemented. They are unsupported
// during template dispatch, and the compiler will raise a compile error if
// either function is encountered.
}
// ── json ──────────────────────────────────────────────────────────────

View File

@@ -50,8 +50,10 @@ pub(super) struct Compiler {
pub(super) alias_modifiable: BTreeMap<String, bool>,
/// Default values for policy parameters.
pub(super) parameter_defaults: Option<Value>,
/// Cached register for the parameter defaults literal.
pub(super) cached_defaults_reg: Option<u8>,
/// Cached literal-table index for `parameter_defaults` (or an empty object
/// when no defaults exist). Populated on first `parameters()` call to avoid
/// repeated O(n) literal-table scans and deep `Value` clones.
pub(super) cached_defaults_literal_idx: Option<u16>,
/// When set, field conditions resolve against this register instead of
/// `input.resource`. Used for `existenceCondition`.
pub(super) resource_override_reg: Option<u8>,
@@ -126,9 +128,6 @@ impl Compiler {
if let Some(r) = self.cached_context_reg {
floor = floor.max(r.saturating_add(1));
}
if let Some(r) = self.cached_defaults_reg {
floor = floor.max(r.saturating_add(1));
}
self.register_counter = floor;
}

View File

@@ -21,11 +21,13 @@ use anyhow::{anyhow, bail, Result};
use crate::languages::azure_policy::ast::{
EffectKind, EffectNode, Expr, ExprLiteral, JsonValue, ObjectEntry, PolicyRule,
};
use crate::languages::azure_policy::compiler::utils::json_value_to_runtime;
use crate::rvm::instructions::ObjectCreateParams;
use crate::rvm::Instruction;
use crate::Value;
use super::core::Compiler;
use super::expressions::check_json_depth;
impl Compiler {
// -- main dispatch ------------------------------------------------------
@@ -451,9 +453,11 @@ impl Compiler {
/// Build cross-resource effect details for the returned result object.
///
/// Preserves all detail fields except `existenceCondition` (which is
/// compiled and evaluated inline). Known Azure fields are emitted with
/// canonical casing regardless of source casing.
/// Only emits `roleDefinitionIds` and `type` into the structured result.
/// All other fields (`existenceCondition`, `deployment`, `name`,
/// `resourceGroupName`, etc.) are either evaluated inline during
/// compilation or are ARM deployment metadata that the policy evaluation
/// engine does not interpret.
pub(super) fn compile_cross_resource_details(
&mut self,
effect_name_reg: u8,
@@ -467,17 +471,26 @@ impl Compiler {
let mut detail_keys: Vec<(u16, u8)> = Vec::new();
for ObjectEntry { key, value, .. } in entries {
// existenceCondition is evaluated inline — not included in result.
if key.eq_ignore_ascii_case("existenceCondition") {
continue;
// Only emit `roleDefinitionIds` and `type` into the structured
// result. All other fields (existenceCondition, deployment,
// name, resourceGroupName, etc.) are either evaluated inline
// during compilation or are ARM deployment metadata that the
// policy evaluation engine does not interpret.
if key.eq_ignore_ascii_case("roleDefinitionIds") {
check_json_depth(value, 0).map_err(|_| {
value
.span()
.error("JSON value nesting exceeds maximum depth")
})?;
let val = json_value_to_runtime(value)?;
let reg = self.load_literal(val, value.span())?;
let key_idx = self.add_literal_u16(Value::from("roleDefinitionIds"))?;
detail_keys.push((key_idx, reg));
} else if key.eq_ignore_ascii_case("type") {
let reg = self.compile_json_value(value, value.span())?;
let key_idx = self.add_literal_u16(Value::from("type"))?;
detail_keys.push((key_idx, reg));
}
let reg = self.compile_json_value(value, value.span())?;
// Canonicalize known Azure field names.
let canonical_key = canonicalize_detail_key(key);
let key_idx = self.add_literal_u16(Value::from(canonical_key))?;
detail_keys.push((key_idx, reg));
}
if detail_keys.is_empty() {
@@ -576,21 +589,29 @@ impl Compiler {
Some(effect_name.to_string())
}
/// Map a lowercase effect name string to its `EffectKind`.
pub(super) fn effect_kind_from_string(effect_name: &str) -> Option<EffectKind> {
let normalized = effect_name.to_lowercase();
Some(match normalized.as_str() {
"deny" => EffectKind::Deny,
"audit" => EffectKind::Audit,
"append" => EffectKind::Append,
"auditifnotexists" => EffectKind::AuditIfNotExists,
"deployifnotexists" => EffectKind::DeployIfNotExists,
"disabled" => EffectKind::Disabled,
"modify" => EffectKind::Modify,
"denyaction" => EffectKind::DenyAction,
"manual" => EffectKind::Manual,
_ => return None,
})
/// Map an effect name string, matched case-insensitively, to its `EffectKind`.
pub(super) const fn effect_kind_from_string(effect_name: &str) -> Option<EffectKind> {
if effect_name.eq_ignore_ascii_case("deny") {
Some(EffectKind::Deny)
} else if effect_name.eq_ignore_ascii_case("audit") {
Some(EffectKind::Audit)
} else if effect_name.eq_ignore_ascii_case("append") {
Some(EffectKind::Append)
} else if effect_name.eq_ignore_ascii_case("auditIfNotExists") {
Some(EffectKind::AuditIfNotExists)
} else if effect_name.eq_ignore_ascii_case("deployIfNotExists") {
Some(EffectKind::DeployIfNotExists)
} else if effect_name.eq_ignore_ascii_case("disabled") {
Some(EffectKind::Disabled)
} else if effect_name.eq_ignore_ascii_case("modify") {
Some(EffectKind::Modify)
} else if effect_name.eq_ignore_ascii_case("denyAction") {
Some(EffectKind::DenyAction)
} else if effect_name.eq_ignore_ascii_case("manual") {
Some(EffectKind::Manual)
} else {
None
}
}
// -- host await request -------------------------------------------------
@@ -731,10 +752,10 @@ fn detect_effect_family_from_details(rule: &PolicyRule) -> EffectFamily {
let mut has_operations = false;
for entry in entries {
match entry.key.to_lowercase().as_str() {
"type" => has_type = true,
"operations" => has_operations = true,
_ => {}
if entry.key.eq_ignore_ascii_case("type") {
has_type = true;
} else if entry.key.eq_ignore_ascii_case("operations") {
has_operations = true;
}
}
@@ -748,8 +769,8 @@ fn detect_effect_family_from_details(rule: &PolicyRule) -> EffectFamily {
EffectFamily::Modify
} else {
// Check for Append-shaped object: { "field": …, "value": … }
let has_field = entries.iter().any(|e| e.key.to_lowercase() == "field");
let has_value = entries.iter().any(|e| e.key.to_lowercase() == "value");
let has_field = entries.iter().any(|e| e.key.eq_ignore_ascii_case("field"));
let has_value = entries.iter().any(|e| e.key.eq_ignore_ascii_case("value"));
if has_field && has_value {
EffectFamily::Append
} else {
@@ -776,25 +797,6 @@ fn unescape_arm_literal(s: &str) -> alloc::string::String {
.map_or_else(|| s.into(), |rest| format!("[{rest}"))
}
/// Canonicalize known Azure Policy detail field names to their standard casing.
///
/// Case-insensitive matching produces the canonical form used by Azure;
/// unknown keys are passed through unchanged.
fn canonicalize_detail_key(key: &str) -> alloc::string::String {
match key.to_lowercase().as_str() {
"roledefinitionids" => "roleDefinitionIds".into(),
"type" => "type".into(),
"name" => "name".into(),
"kind" => "kind".into(),
"resourcegroupname" => "resourceGroupName".into(),
"existencescope" => "existenceScope".into(),
"deployment" => "deployment".into(),
"deploymentscope" => "deploymentScope".into(),
"evaluationdelay" => "evaluationDelay".into(),
_ => key.into(),
}
}
/// Build an RVM object from a set of `(literal_key_idx, value_reg)` pairs.
///
/// This is the common pattern used throughout effect compilation:

View File

@@ -18,12 +18,15 @@ use alloc::vec::Vec;
use anyhow::{bail, Result};
use crate::languages::azure_policy::compiler::utils::json_value_to_runtime;
use crate::languages::azure_policy::ast::{JsonValue, ObjectEntry};
use crate::rvm::instructions::ArrayCreateParams;
use crate::rvm::Instruction;
use super::core::Compiler;
use super::effects::build_object_from_keys;
use super::expressions::check_json_depth;
use crate::Value;
impl Compiler {
@@ -37,8 +40,12 @@ impl Compiler {
details: Option<&JsonValue>,
span: &crate::lexer::Span,
) -> Result<u8> {
// When details is absent or not an object, return the bare effect.
// Azure Policy accepts this — the effect is reported for compliance
// evaluation even when remediation details are missing. Erroring here
// would reject policies that the real engine considers valid.
let Some(JsonValue::Object(_, entries)) = details else {
bail!(span.error("Modify effect requires 'details' to be an object"));
return self.wrap_effect_result(effect_name_reg, None, span);
};
// Extract roleDefinitionIds and operations from details entries.
@@ -185,8 +192,21 @@ impl Compiler {
has_value = true;
}
"condition" => {
// Condition may contain template expressions.
let reg = self.compile_json_value(value, value.span())?;
// The `condition` field is NOT evaluated during policy
// rule evaluation. It is a remediation instruction:
// when Azure's remediation engine applies the modify
// effect it evaluates this condition against the
// resource to decide whether to execute the specific
// operation. We preserve it verbatim (as a literal
// string) so the consumer receives the original
// expression, e.g. `"[equals(field('tags.env'), '')]"`.
check_json_depth(value, 0).map_err(|_| {
value
.span()
.error("JSON value nesting exceeds maximum depth")
})?;
let runtime_value = json_value_to_runtime(value)?;
let reg = self.load_literal(runtime_value, value.span())?;
let key_idx = self.add_literal_u16(Value::from("condition"))?;
op_keys.push((key_idx, reg));
}
@@ -227,7 +247,9 @@ impl Compiler {
span: &crate::lexer::Span,
) -> Result<u8> {
let Some(details) = details else {
bail!(span.error("Append effect requires 'details'"));
// When details is absent, return the bare effect. Same rationale
// as modify: Azure Policy accepts this for compliance evaluation.
return self.wrap_effect_result(effect_name_reg, None, span);
};
let item_regs = match details {

View File

@@ -26,7 +26,13 @@ impl Compiler {
span: &crate::lexer::Span,
) -> Result<u8> {
match voe {
ValueOrExpr::Value(value) => self.compile_json_value(value, span),
// The parser's `json_to_value_or_expr` already resolved template
// expressions and unescaped `[[` → `[` literals. Skip the
// top-level template-expression check so an unescaped string like
// `"[not-an-expression]"` (originally `"[[not-an-expression]"`) is
// not re-parsed as a template expression. Nested arrays/objects
// still get full template-expression handling at depth > 0.
ValueOrExpr::Value(value) => self.compile_json_value_inner(value, span, 0, true),
ValueOrExpr::Expr { expr, .. } => self.compile_expr(expr),
}
}
@@ -36,14 +42,23 @@ impl Compiler {
value: &crate::languages::azure_policy::ast::JsonValue,
span: &crate::lexer::Span,
) -> Result<u8> {
self.compile_json_value_inner(value, span, 0)
self.compile_json_value_inner(value, span, 0, false)
}
/// Compile a JSON value to a register.
///
/// `resolved_top` — when `true`, the top-level string has already been
/// through `json_to_value_or_expr` (template expressions extracted, `[[`
/// unescaped). Skip the template-expression check at this level so that
/// an unescaped `"[literal]"` is not re-parsed. Recursive calls for
/// array elements and object values always pass `false` since those
/// nested values have not been pre-resolved.
fn compile_json_value_inner(
&mut self,
value: &crate::languages::azure_policy::ast::JsonValue,
span: &crate::lexer::Span,
depth: usize,
resolved_top: bool,
) -> Result<u8> {
if depth > MAX_JSON_DEPTH {
bail!(span.error(&format!(
@@ -55,18 +70,22 @@ impl Compiler {
use crate::languages::azure_policy::parser::is_template_expr;
// Standalone string template expressions like `"[concat(...)]"`
// must be compiled so they evaluate at runtime.
if let JsonValue::Str(str_span, s) = value {
if is_template_expr(s) {
let inner = s
.strip_prefix('[')
.and_then(|inner| inner.strip_suffix(']'))
.ok_or_else(|| {
str_span.error("invalid template expression: missing brackets")
})?;
let expr = ExprParser::parse_from_brackets(inner, str_span)
.map_err(|e| anyhow!("{}", e))?;
return self.compile_expr(&expr);
// must be compiled so they evaluate at runtime. Skip this check
// when the caller has already resolved template expressions (e.g.
// values coming from `ValueOrExpr::Value`).
if !resolved_top {
if let JsonValue::Str(str_span, s) = value {
if is_template_expr(s) {
let inner = s
.strip_prefix('[')
.and_then(|inner| inner.strip_suffix(']'))
.ok_or_else(|| {
str_span.error("invalid template expression: missing brackets")
})?;
let expr = ExprParser::parse_from_brackets(inner, str_span)
.map_err(|e| anyhow!("{}", e))?;
return self.compile_expr(&expr);
}
}
}
@@ -111,7 +130,7 @@ impl Compiler {
) -> Result<u8> {
let mut element_regs = Vec::with_capacity(items.len());
for item in items {
let reg = self.compile_json_value_inner(item, item.span(), depth)?;
let reg = self.compile_json_value_inner(item, item.span(), depth, false)?;
element_regs.push(reg);
}
@@ -140,7 +159,8 @@ impl Compiler {
) -> Result<u8> {
let mut keys: Vec<(u16, u8)> = Vec::with_capacity(entries.len());
for entry in entries {
let val_reg = self.compile_json_value_inner(&entry.value, entry.value.span(), depth)?;
let val_reg =
self.compile_json_value_inner(&entry.value, entry.value.span(), depth, false)?;
let key_idx = self.add_literal_u16(Value::from(entry.key.clone()))?;
keys.push((key_idx, val_reg));
}
@@ -228,17 +248,26 @@ impl Compiler {
let input_reg = self.load_input(span)?;
let params_reg =
self.emit_chained_index_literal_path(input_reg, &["parameters"], span)?;
let defaults_reg = if let Some(reg) = self.cached_defaults_reg {
reg
} else {
let reg = if let Some(ref defaults) = self.parameter_defaults {
self.load_literal(defaults.clone(), span)?
} else {
self.load_literal(Value::new_object(), span)?
};
self.cached_defaults_reg = Some(reg);
reg
let defaults_literal_idx = match self.cached_defaults_literal_idx {
Some(idx) => idx,
None => {
let val = self
.parameter_defaults
.clone()
.unwrap_or_else(Value::new_object);
let idx = self.add_literal_u16(val)?;
self.cached_defaults_literal_idx = Some(idx);
idx
}
};
let defaults_reg = self.alloc_register()?;
self.emit(
Instruction::Load {
dest: defaults_reg,
literal_idx: defaults_literal_idx,
},
span,
);
let name_reg = self.load_literal(Value::from(param_name), span)?;
self.emit_builtin_call(
"azure.policy.get_parameter",

View File

@@ -302,9 +302,10 @@ impl Compiler {
// -- JSON / misc functions --
"json" => self.emit_builtin_call_from_args("azure.policy.fn.json", args, span)?,
"join" => self.emit_builtin_call_from_args("azure.policy.fn.join", args, span)?,
"guid" => self.emit_builtin_call_from_args("azure.policy.fn.guid", args, span)?,
"uniquestring" => {
self.emit_builtin_call_from_args("azure.policy.fn.unique_string", args, span)?
"guid" | "uniquestring" => {
bail!(span.error(&alloc::format!(
"unsupported template function '{function_name}' (deployment-template functions are not evaluated for compliance)"
)));
}
"items" => self.emit_builtin_call_from_args("azure.policy.fn.items", args, span)?,
"indexfromend" => {

View File

@@ -7,7 +7,7 @@
pub mod aliases;
pub mod ast;
#[cfg(feature = "rvm")]
pub(crate) mod compiler;
pub mod compiler;
pub mod expr;
pub mod parser;
pub mod strings;

View File

@@ -6,6 +6,7 @@
use alloc::boxed::Box;
use alloc::string::{String, ToString as _};
use alloc::vec::Vec;
use core::num::NonZeroU32;
use crate::lexer::{Lexer, Source, Span, Token, TokenKind};
@@ -146,9 +147,22 @@ pub(super) struct Parser<'source> {
}
impl<'source> Parser<'source> {
/// 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.
pub const MAX_COL: u32 = 8192;
// Safety: 8192 != 0, so this is always `Some`.
const MAX_COL_NZ: Option<NonZeroU32> = NonZeroU32::new(Self::MAX_COL);
/// Create a new parser for the given source.
///
/// Uses [`Self::MAX_COL`] because Azure Policy definitions are often
/// serialized as single-line JSON with deeply nested template expressions.
pub fn new(source: &'source Source) -> Result<Self, ParseError> {
Self::new_with_max_col(source, None)
Self::new_with_max_col(source, Self::MAX_COL_NZ)
}
/// Create a new parser with an optional column-width override.

View File

@@ -43,6 +43,13 @@ 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
// ============================================================================
@@ -62,12 +69,14 @@ 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 optional column-width override.
/// 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)?;
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 {
@@ -92,12 +101,14 @@ pub fn parse_policy_definition(source: &Source) -> Result<PolicyDefinition, Pars
parse_policy_definition_with_max_col(source, None)
}
/// Like [`parse_policy_definition`] but with an optional column-width override.
/// 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)?;
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 {