From b989888dab8f61c2e95d94201483cc8b967d4b7d Mon Sep 17 00:00:00 2001 From: Anand Krishnamoorthi <35780660+anakrish@users.noreply.github.com> Date: Mon, 27 Apr 2026 11:31:26 -0500 Subject: [PATCH] feat(azure-policy): implement effect compilation and metadata population (#691) Replace the stub implementations in effects.rs, effects_modify_append.rs, and metadata.rs with full working code. Effect compilation dispatches all effect kinds (Deny, Audit, Modify, Append, AuditIfNotExists, DeployIfNotExists, etc.) including parameterized effects that resolve at runtime via [parameters('effect')]. Cross-resource effects (AINE/DINE) emit a HostAwait to fetch the related resource and evaluate an optional existenceCondition against it. Modify and Append effects compile their operation/detail arrays, including template expressions in values. Metadata recording tracks which policy features are used during compilation (field kinds, aliases, operators, resource types, count, wildcards) and writes them into the program annotations so the runtime can inspect capabilities without re-analyzing the AST. Definition-level metadata (display name, category, version, parameter names, etc.) is also extracted. Detail field values in AINE/DINE (type, name, resourceGroupName) are compiled as expressions rather than frozen as literals, so template expressions like [field('name')] are properly evaluated at runtime. Signed-off-by: Anand Krishnamoorthi --- .../azure_policy/compiler/effects.rs | 835 +++++++++++++++++- .../compiler/effects_modify_append.rs | 317 ++++++- .../azure_policy/compiler/expressions.rs | 160 +++- .../azure_policy/compiler/metadata.rs | 298 ++++++- src/languages/azure_policy/compiler/mod.rs | 12 +- 5 files changed, 1545 insertions(+), 77 deletions(-) diff --git a/src/languages/azure_policy/compiler/effects.rs b/src/languages/azure_policy/compiler/effects.rs index 09701f1..3406877 100644 --- a/src/languages/azure_policy/compiler/effects.rs +++ b/src/languages/azure_policy/compiler/effects.rs @@ -1,30 +1,839 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -#![allow(dead_code)] +#![allow(clippy::pattern_type_mismatch)] -//! Effect compilation (dispatch + cross-resource). +//! Effect compilation — dispatches the policy effect and compiles +//! cross-resource (AINE/DINE) evaluation. //! -//! Stub — real implementation added in a later commit. +//! The effect is the "then" clause of a policy rule. It may be a simple +//! literal (`"Deny"`) or a parameterized expression +//! (`[parameters('effect')]`). Cross-resource effects involve a `HostAwait` +//! to fetch a related resource and an optional `existenceCondition` evaluated +//! inline. -use anyhow::{bail, Result}; +use alloc::collections::BTreeMap; +use alloc::format; +use alloc::string::ToString as _; +use alloc::vec::Vec; -use crate::languages::azure_policy::ast::PolicyRule; +use anyhow::{anyhow, bail, Result}; + +use crate::languages::azure_policy::ast::{ + EffectKind, EffectNode, Expr, ExprLiteral, JsonValue, ObjectEntry, PolicyRule, +}; +use crate::rvm::instructions::ObjectCreateParams; +use crate::rvm::Instruction; +use crate::Value; use super::core::Compiler; impl Compiler { - pub(super) fn compile_effect(&mut self, _rule: &PolicyRule) -> Result { - let _ = self; - bail!("effect compilation not yet implemented") + // -- main dispatch ------------------------------------------------------ + + /// Compile the effect clause of a policy rule. + /// + /// Handles both literal effect kinds (`Deny`, `Audit`, …) and + /// parameterised effects (`[parameters('effect')]`), routing to the + /// appropriate compilation path. + pub(super) fn compile_effect(&mut self, rule: &PolicyRule) -> Result { + let effect = &rule.then_block.effect; + let span = &effect.span; + + // --- Parameterized / unknown effect kind --- + if matches!(effect.kind, EffectKind::Other) { + return self.compile_parameterized_effect(rule); + } + + // --- Well-known effect kinds --- + match &effect.kind { + EffectKind::AuditIfNotExists | EffectKind::DeployIfNotExists => { + let effect_name_reg = self.load_literal(Value::from(effect.raw.clone()), span)?; + self.compile_cross_resource_effect(rule, effect_name_reg) + } + EffectKind::Modify | EffectKind::Append => { + let effect_name_reg = self.load_literal(Value::from(effect.raw.clone()), span)?; + self.compile_effect_with_details( + &effect.kind, + effect_name_reg, + rule.then_block.details.as_ref(), + span, + ) + } + EffectKind::Disabled => { + // Azure Policy: Disabled means skip evaluation entirely. + self.emit_return_undefined(span) + } + EffectKind::Deny | EffectKind::Audit | EffectKind::DenyAction | EffectKind::Manual => { + let name_reg = self.load_literal(Value::from(effect.raw.clone()), span)?; + self.wrap_effect_result(name_reg, None, span) + } + // Unreachable — early return above handles Other — defensive fallback. + EffectKind::Other => { + bail!(span.error(&format!("unsupported effect kind: {}", effect.raw))) + } + } } + /// Compile a parameterized effect (`EffectKind::Other`). + /// + /// Dispatches primarily based on the `then.details` structure and + /// `then.existence_condition`: + /// - Object with `type` key or `existence_condition` present → cross-resource (AINE/DINE) + /// - Object with `operations` key → Modify + /// - Array → Append + /// + /// Falls back to parameter-default resolution when details is absent. + pub(super) fn compile_parameterized_effect(&mut self, rule: &PolicyRule) -> Result { + let effect = &rule.then_block.effect; + let span = &effect.span; + + // Primary dispatch: infer effect family from then.details structure. + // This is correct for Azure Policy because the details shape determines + // compilation semantics regardless of the runtime effect name. Azure + // definitions don't mix effect families in practice (e.g. Modify-shaped + // details with an Audit effect). The disabled guard on each structured + // path handles the Disabled ↔ any-effect interchangeability. + let structural = detect_effect_family_from_details(rule); + + match structural { + EffectFamily::CrossResource => { + let effect_name_reg = self.compile_effect_name_expression(effect)?; + return self.compile_cross_resource_effect(rule, effect_name_reg); + } + EffectFamily::Modify => { + let effect_name_reg = self.compile_bracket_or_literal_expression(effect)?; + self.emit_disabled_guard(effect_name_reg, span)?; + return self.compile_effect_with_details( + &EffectKind::Modify, + effect_name_reg, + rule.then_block.details.as_ref(), + span, + ); + } + EffectFamily::Append => { + let effect_name_reg = self.compile_bracket_or_literal_expression(effect)?; + self.emit_disabled_guard(effect_name_reg, span)?; + return self.compile_effect_with_details( + &EffectKind::Append, + effect_name_reg, + rule.then_block.details.as_ref(), + span, + ); + } + EffectFamily::Unknown => { + // Fall through to parameter-default resolution. + } + } + + // Secondary dispatch: resolve from parameter default when details + // structure is absent or ambiguous. + let resolved = self.resolve_effect_kind(effect); + + if resolved == EffectKind::AuditIfNotExists || resolved == EffectKind::DeployIfNotExists { + let effect_name_reg = self.compile_effect_name_expression(effect)?; + return self.compile_cross_resource_effect(rule, effect_name_reg); + } + + if matches!(resolved, EffectKind::Modify | EffectKind::Append) { + let effect_name_reg = self.compile_bracket_or_literal_expression(effect)?; + self.emit_disabled_guard(effect_name_reg, span)?; + return self.compile_effect_with_details( + &resolved, + effect_name_reg, + rule.then_block.details.as_ref(), + span, + ); + } + + // Generic bracket expression — compile and wrap. + if is_bracket_expression(&effect.raw) { + let inner = effect + .raw + .strip_prefix('[') + .and_then(|s| s.strip_suffix(']')) + .ok_or_else( + || anyhow!(span.error("invalid effect expression: missing brackets")), + )?; + let expr = + crate::languages::azure_policy::expr::ExprParser::parse_from_brackets(inner, span) + .map_err(|error| anyhow!("invalid effect expression: {}", error))?; + let name_reg = self.compile_expr(&expr)?; + self.emit_disabled_guard(name_reg, span)?; + return self.wrap_effect_result(name_reg, None, span); + } + + // Plain literal string — load and wrap. + // Unescape ARM `[[` escape so the runtime value is correct. + let name_reg = self.load_literal(Value::from(unescape_arm_literal(&effect.raw)), span)?; + self.emit_disabled_guard(name_reg, span)?; + self.wrap_effect_result(name_reg, None, span) + } + + // -- result wrapping ---------------------------------------------------- + + /// Wrap an effect name register into `{ "effect": }` or + /// `{ "effect": , "details":
}`. pub(super) fn wrap_effect_result( &mut self, - _effect_name_reg: u8, - _details_reg: Option, - _span: &crate::lexer::Span, + effect_name_reg: u8, + details_reg: Option, + span: &crate::lexer::Span, ) -> Result { - let _ = self; - bail!("wrap_effect_result not yet implemented") + let mut keys: Vec<(u16, u8)> = Vec::new(); + let effect_key_idx = self.add_literal_u16(Value::from("effect"))?; + keys.push((effect_key_idx, effect_name_reg)); + + if let Some(det_reg) = details_reg { + let details_key_idx = self.add_literal_u16(Value::from("details"))?; + keys.push((details_key_idx, det_reg)); + } + + build_object_from_keys(self, keys, span) + } + + /// Route to Modify or Append detail compilation, falling back to a bare + /// effect result for other kinds. + pub(super) fn compile_effect_with_details( + &mut self, + kind: &EffectKind, + effect_name_reg: u8, + details: Option<&JsonValue>, + span: &crate::lexer::Span, + ) -> Result { + match kind { + EffectKind::Modify => self.compile_modify_details(effect_name_reg, details, span), + EffectKind::Append => self.compile_append_details(effect_name_reg, details, span), + _ => self.wrap_effect_result(effect_name_reg, None, span), + } + } + + // -- effect name helpers ------------------------------------------------ + + /// Compile the raw effect string into a runtime register. + /// + /// Bracket expressions like `[parameters('effect')]` are compiled so the + /// value is resolved at runtime. Plain strings are loaded as literals. + pub(super) fn compile_effect_name_expression(&mut self, effect: &EffectNode) -> Result { + let span = &effect.span; + if is_bracket_expression(&effect.raw) { + let inner = effect + .raw + .strip_prefix('[') + .and_then(|s| s.strip_suffix(']')) + .ok_or_else( + || anyhow!(span.error("invalid effect expression: missing brackets")), + )?; + let expr = + crate::languages::azure_policy::expr::ExprParser::parse_from_brackets(inner, span) + .map_err(|error| anyhow!("invalid effect expression: {}", error))?; + self.compile_expr(&expr) + } else { + self.load_literal(Value::from(unescape_arm_literal(&effect.raw)), span) + } + } + + /// Compile a bracket expression or fall back to a literal load. + pub(super) fn compile_bracket_or_literal_expression( + &mut self, + effect: &EffectNode, + ) -> Result { + self.compile_effect_name_expression(effect) + } + + // -- cross-resource effects (AINE / DINE) -------------------------------- + + /// Compile a cross-resource effect (AuditIfNotExists / DeployIfNotExists). + /// + /// Two-phase evaluation: + /// 1. `HostAwait` requests the related resource from the host. + /// 2. The `existenceCondition` (if any) is evaluated against the returned + /// resource inline. If absent, existence is checked via `PolicyExists`. + /// + /// Host protocol: + /// id = `"azure.policy.existence_check"` + /// arg = `{ operation: "lookup_related_resources", type, name, … }` + /// response = related resource object, or `null` if not found + pub(super) fn compile_cross_resource_effect( + &mut self, + rule: &PolicyRule, + effect_name_reg: u8, + ) -> Result { + let span = &rule.then_block.effect.span; + + let Some(details) = rule.then_block.details.as_ref() else { + bail!(span.error("cross-resource effects (AINE/DINE) require then.details")); + }; + + let JsonValue::Object(_, _) = details else { + bail!(span + .error("cross-resource effects (AINE/DINE) require then.details to be an object")); + }; + + // Guard: if the runtime effect is "Disabled", skip the existence + // check entirely and return Undefined (Compliant). + self.emit_disabled_guard(effect_name_reg, span)?; + + // Phase 1: Request related resource from host via HostAwait. + let related_resource_reg = self.emit_host_await_lookup(details, span)?; + + // Phase 2: Evaluate existence. + let exists_reg = self.evaluate_existence(rule, related_resource_reg, span)?; + + // Phase 3: Produce result. + // If exists_reg is truthy → compliant → return Undefined. + // If exists_reg is falsy → non-compliant → return the effect object. + let not_exists_reg = self.alloc_register()?; + self.emit( + Instruction::PolicyCondition { + dest: not_exists_reg, + left: exists_reg, + right: 0, + op: crate::rvm::instructions::PolicyOp::Not, + }, + span, + ); + self.emit( + Instruction::ReturnUndefinedIfNotTrue { + condition: not_exists_reg, + }, + span, + ); + + // Build structured result with roleDefinitionIds / type if present. + self.compile_cross_resource_details(effect_name_reg, details, span) + } + + /// Unconditionally return Undefined from the compiled program. + /// + /// Used for `Disabled` effects — Azure Policy skips evaluation entirely. + pub(super) fn emit_return_undefined(&mut self, span: &crate::lexer::Span) -> Result { + let false_reg = self.load_literal(Value::Bool(false), span)?; + self.emit( + Instruction::ReturnUndefinedIfNotTrue { + condition: false_reg, + }, + span, + ); + // The return register is never reached (the instruction above always + // returns Undefined), but the caller requires a register. + Ok(false_reg) + } + + /// Emit instructions that return Undefined when the runtime effect name + /// equals `"Disabled"` — used to short-circuit parameterized effect evaluation. + pub(super) fn emit_disabled_guard( + &mut self, + effect_name_reg: u8, + span: &crate::lexer::Span, + ) -> Result<()> { + let disabled_reg = self.load_literal(Value::from("Disabled"), span)?; + let is_disabled_reg = self.alloc_register()?; + self.emit( + Instruction::PolicyCondition { + dest: is_disabled_reg, + left: effect_name_reg, + right: disabled_reg, + op: crate::rvm::instructions::PolicyOp::Equals, + }, + span, + ); + // Negate: not_disabled is false when disabled → ReturnUndefined fires. + let not_disabled_reg = self.alloc_register()?; + self.emit( + Instruction::PolicyCondition { + dest: not_disabled_reg, + left: is_disabled_reg, + right: 0, + op: crate::rvm::instructions::PolicyOp::Not, + }, + span, + ); + self.emit( + Instruction::ReturnUndefinedIfNotTrue { + condition: not_disabled_reg, + }, + span, + ); + Ok(()) + } + + /// Emit a `HostAwait` instruction to request a related resource lookup. + /// + /// Detail fields like `type`, `name`, `resourceGroupName`, and + /// `existenceScope` may contain template expressions (e.g. + /// `"[field('name')]"`) that must be compiled rather than frozen as + /// literals. + pub(super) fn emit_host_await_lookup( + &mut self, + details: &JsonValue, + span: &crate::lexer::Span, + ) -> Result { + let request_reg = self.build_host_await_request(details, span)?; + let id_reg = self.load_literal(Value::from("azure.policy.existence_check"), span)?; + + let related_resource_reg = self.alloc_register()?; + self.emit( + Instruction::HostAwait { + dest: related_resource_reg, + arg: request_reg, + id: id_reg, + }, + span, + ); + Ok(related_resource_reg) + } + + /// Evaluate whether the related resource satisfies the existence check. + /// + /// With an `existenceCondition`: checks resource exists AND condition + /// passes (field references resolve against the related resource). + /// Without: simply checks whether the resource was found (non-null). + pub(super) fn evaluate_existence( + &mut self, + rule: &PolicyRule, + related_resource_reg: u8, + span: &crate::lexer::Span, + ) -> Result { + if let Some(ref existence_condition) = rule.then_block.existence_condition { + // First check that the related resource was actually found. + // Without this guard, field lookups on a null response yield + // Undefined and operators like PolicyNotEquals(Undefined, _) + // return true, incorrectly marking a missing resource as + // compliant. + let true_reg = self.load_literal(Value::Bool(true), span)?; + let resource_found_reg = self.alloc_register()?; + self.emit( + Instruction::PolicyCondition { + dest: resource_found_reg, + left: related_resource_reg, + right: true_reg, + op: crate::rvm::instructions::PolicyOp::Exists, + }, + span, + ); + + // Compile existenceCondition with field references resolving + // against the related resource instead of input.resource. + // Save/restore to ensure cleanup even if compile_constraint fails. + let prev_override = self.resource_override_reg; + self.resource_override_reg = Some(related_resource_reg); + let cond_result = self.compile_constraint(existence_condition); + self.resource_override_reg = prev_override; + let cond_reg = cond_result?; + + // Combine: resource must exist AND condition must pass. + let and_reg = self.alloc_register()?; + self.emit( + Instruction::And { + dest: and_reg, + left: resource_found_reg, + right: cond_reg, + }, + span, + ); + Ok(and_reg) + } else { + // No existenceCondition — just check resource existence. + let true_reg = self.load_literal(Value::Bool(true), span)?; + let dest = self.alloc_register()?; + self.emit( + Instruction::PolicyCondition { + dest, + left: related_resource_reg, + right: true_reg, + op: crate::rvm::instructions::PolicyOp::Exists, + }, + span, + ); + Ok(dest) + } + } + + /// 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. + pub(super) fn compile_cross_resource_details( + &mut self, + effect_name_reg: u8, + details: &JsonValue, + span: &crate::lexer::Span, + ) -> Result { + let JsonValue::Object(_, entries) = details else { + return self.wrap_effect_result(effect_name_reg, None, span); + }; + + 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; + } + + 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() { + return self.wrap_effect_result(effect_name_reg, None, span); + } + + let details_dest = build_object_from_keys(self, detail_keys, span)?; + self.wrap_effect_result(effect_name_reg, Some(details_dest), span) + } + + // -- JSON value / expression helpers ------------------------------------ + + /// Compile a JSON value that may contain template expressions. + /// + /// Delegates to [`compile_json_value`] which handles bracket strings, + /// arrays with embedded template expressions, and plain literals. + pub(super) fn compile_value_or_expr_from_json( + &mut self, + value: &JsonValue, + span: &crate::lexer::Span, + ) -> Result { + self.compile_json_value(value, span) + } + + // -- effect kind resolution --------------------------------------------- + + /// Resolve `EffectKind::Other` to a concrete kind using parameter defaults. + pub(super) fn resolve_effect_kind(&self, effect: &EffectNode) -> EffectKind { + match effect.kind { + EffectKind::Other => self + .resolve_effect_kind_from_parameter_default(effect) + .unwrap_or_else(|| effect.kind.clone()), + _ => effect.kind.clone(), + } + } + + /// Attempt to resolve an effect kind from `[parameters('name')]` by + /// looking up the parameter's default value. + pub(super) fn resolve_effect_kind_from_parameter_default( + &self, + effect: &EffectNode, + ) -> Option { + let name = self.extract_parameter_default_string(effect)?; + Self::effect_kind_from_string(&name) + } + + /// Attempt to resolve an effect name string from `[parameters('name')]` + /// by looking up the parameter's default value. + pub(super) fn resolve_effect_name_from_parameter_default( + &self, + effect: &EffectNode, + ) -> Option { + self.extract_parameter_default_string(effect) + } + + /// Common helper: parse a `[parameters('name')]` expression, look up the + /// parameter in `self.parameter_defaults`, and return the string value. + pub(super) fn extract_parameter_default_string( + &self, + effect: &EffectNode, + ) -> Option { + let raw = effect.raw.as_str(); + if !is_bracket_expression(raw) { + return None; + } + + let inner = raw.strip_prefix('[').and_then(|s| s.strip_suffix(']'))?; + let expr = crate::languages::azure_policy::expr::ExprParser::parse_from_brackets( + inner, + &effect.span, + ) + .ok()?; + + // Must be `parameters('paramName')` — a single-argument call. + let parameter_name = match expr { + Expr::Call { func, args, .. } if args.len() == 1 => { + let first_arg = args.first()?; + match (*func, first_arg) { + ( + Expr::Ident { name, .. }, + Expr::Literal { + value: ExprLiteral::String(param_name), + .. + }, + ) if name.eq_ignore_ascii_case("parameters") => param_name.clone(), + _ => return None, + } + } + _ => return None, + }; + + let defaults = self.parameter_defaults.as_ref()?; + let defaults_obj = defaults.as_object().ok()?; + let default_effect = defaults_obj.get(&Value::from(parameter_name))?; + let effect_name = default_effect.as_string().ok()?; + 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 { + 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, + }) + } + + // -- host await request ------------------------------------------------- + + /// Build the request object for `HostAwait` related-resource lookup. + /// + /// Produces `{ "operation": "lookup_related_resources", "type": …, … }` + /// by extracting known keys from the effect's `details` block. + /// + /// Detail field values may contain template expressions (e.g. + /// `"[concat(field('name'), '/default')]"`), so each value is compiled + /// via [`compile_json_value`] rather than frozen as a static literal. + pub(super) fn build_host_await_request( + &mut self, + details: &JsonValue, + span: &crate::lexer::Span, + ) -> Result { + let mut keys: Vec<(u16, u8)> = Vec::new(); + + // "operation" is always the literal "lookup_related_resources". + let op_reg = self.load_literal(Value::from("lookup_related_resources"), span)?; + let op_key = self.add_literal_u16(Value::from("operation"))?; + keys.push((op_key, op_reg)); + + let JsonValue::Object(_, entries) = details else { + return build_object_from_keys(self, keys, span); + }; + + // 'type' is required for cross-resource lookups and must be a string + // (possibly a template expression like "[parameters('resourceType')]"). + let type_entry = entries + .iter() + .find(|entry| entry.key.eq_ignore_ascii_case("type")); + match type_entry { + None => { + bail!( + span.error("cross-resource effects (AINE/DINE) require 'type' in then.details") + ); + } + Some(entry) => { + if !matches!(&entry.value, JsonValue::Str(_, _)) { + bail!(entry.value.span().error( + "cross-resource effects require 'type' to be a string or expression" + )); + } + } + } + + for key in [ + "type", + "name", + "kind", + "resourceGroupName", + "existenceScope", + ] { + if let Some(entry) = entries + .iter() + .find(|entry| entry.key.eq_ignore_ascii_case(key)) + { + let val_reg = self.compile_json_value(&entry.value, entry.value.span())?; + let key_idx = self.add_literal_u16(Value::from(key))?; + keys.push((key_idx, val_reg)); + } + } + + build_object_from_keys(self, keys, span) + } + + // -- alias modifiability check ------------------------------------------ + + /// Check whether a field path used in a Modify operation targets a + /// modifiable alias. + /// + /// When the alias catalog is loaded, non-modifiable aliases produce a + /// compile-time error. Without an alias catalog, no check is performed. + pub(super) fn check_modify_field_alias( + &self, + field_path: &str, + span: &crate::lexer::Span, + ) -> Result<()> { + if self.alias_modifiable.is_empty() { + return Ok(()); + } + + let lc = field_path.to_lowercase(); + + if let Some(&modifiable) = self.alias_modifiable.get(&lc) { + if !modifiable { + bail!(span.error(&format!( + "alias '{}' is not modifiable (defaultMetadata.attributes != 'Modifiable')", + field_path + ))); + } + } + + // Tags and built-in fields are always modifiable for Modify operations. + Ok(()) } } + +// --------------------------------------------------------------------------- +// Module-private helpers +// --------------------------------------------------------------------------- + +/// Structural effect family detected from `then.details` shape. +#[derive(Debug, PartialEq, Eq)] +enum EffectFamily { + /// Details indicate a cross-resource effect (AINE/DINE): + /// object with `type` key, or `existence_condition` present. + CrossResource, + /// Details indicate Modify: object with `operations` key. + Modify, + /// Details indicate Append: array of `{ field, value }` items. + Append, + /// No details or unrecognizable structure. + Unknown, +} + +/// Detect the effect family from the `then` block structure. +/// +/// This enables correct compilation of parameterized effects even when the +/// parameter default is missing or misleading, by inspecting the structural +/// shape of `then.details` and `then.existence_condition`. +fn detect_effect_family_from_details(rule: &PolicyRule) -> EffectFamily { + // existenceCondition is always cross-resource. + if rule.then_block.existence_condition.is_some() { + return EffectFamily::CrossResource; + } + + let Some(details) = rule.then_block.details.as_ref() else { + return EffectFamily::Unknown; + }; + + match details { + JsonValue::Array(_, _) => EffectFamily::Append, + JsonValue::Object(_, entries) => { + let mut has_type = false; + 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 has_type && has_operations { + // Ambiguous — both cross-resource and Modify markers. + // Fall through to parameter-default resolution. + EffectFamily::Unknown + } else if has_type { + EffectFamily::CrossResource + } else if has_operations { + 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"); + if has_field && has_value { + EffectFamily::Append + } else { + EffectFamily::Unknown + } + } + } + _ => EffectFamily::Unknown, + } +} + +/// Check whether a string is a bracket expression (`[…]` but not `[[…`). +fn is_bracket_expression(s: &str) -> bool { + s.starts_with('[') && s.ends_with(']') && !s.starts_with("[[") +} + +/// Unescape the ARM template double-bracket literal (`[[…` → `[…`). +/// +/// In ARM templates, `[[` at the start of a string is an escape for a literal +/// `[`. This mirrors the unescaping in `json_value_to_runtime` for JSON string +/// values, ensuring effect name literals are consistent. +fn unescape_arm_literal(s: &str) -> alloc::string::String { + s.strip_prefix("[[") + .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: +/// 1. Build a template `BTreeMap` with `Value::Undefined` placeholders. +/// 2. Sort keys by their literal value (BTreeMap order). +/// 3. Emit `ObjectCreate`. +#[allow(clippy::indexing_slicing)] +pub(super) fn build_object_from_keys( + compiler: &mut Compiler, + mut keys: Vec<(u16, u8)>, + span: &crate::lexer::Span, +) -> Result { + // Build template: object with all keys set to Undefined. + let mut template = BTreeMap::new(); + for &(key_idx, _) in &keys { + // SAFETY: key_idx was just returned by `add_literal_u16`, so the + // index is guaranteed to be in bounds. + let key_val = compiler.program.literals[usize::from(key_idx)].clone(); + template.insert(key_val, Value::Undefined); + } + let template_idx = compiler.add_literal_u16(Value::Object(crate::Rc::new(template)))?; + + // Sort keys by literal value (BTreeMap order). + keys.sort_by(|a, b| { + compiler.program.literals[usize::from(a.0)] + .cmp(&compiler.program.literals[usize::from(b.0)]) + }); + + let dest = compiler.alloc_register()?; + let params = ObjectCreateParams { + dest, + template_literal_idx: template_idx, + literal_key_fields: keys, + fields: Vec::new(), + }; + let params_index = compiler + .program + .instruction_data + .add_object_create_params(params); + compiler.emit(Instruction::ObjectCreate { params_index }, span); + Ok(dest) +} diff --git a/src/languages/azure_policy/compiler/effects_modify_append.rs b/src/languages/azure_policy/compiler/effects_modify_append.rs index af24f5f..fe0a411 100644 --- a/src/languages/azure_policy/compiler/effects_modify_append.rs +++ b/src/languages/azure_policy/compiler/effects_modify_append.rs @@ -1,6 +1,319 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +#![allow(clippy::pattern_type_mismatch)] -//! Modify / Append effect detail compilation. +//! Modify and Append effect detail compilation. //! -//! Stub — real implementation added in a later commit. +//! Modify effects contain an array of operations (`add`, `addOrReplace`, +//! `remove`) each targeting a specific field/alias. Append effects contain +//! a `{ "field", "value" }` pair or an array of such pairs. +//! +//! Values within operations may be template expressions (`[concat(…)]`) +//! which are compiled rather than stored as literals. + +use alloc::format; +use alloc::string::String; +use alloc::vec; +use alloc::vec::Vec; + +use anyhow::{bail, Result}; + +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 crate::Value; + +impl Compiler { + // -- Modify details ----------------------------------------------------- + + /// Compile Modify effect details: + /// `{ "effect": "modify", "details": { "roleDefinitionIds": […], "operations": […] } }` + pub(super) fn compile_modify_details( + &mut self, + effect_name_reg: u8, + details: Option<&JsonValue>, + span: &crate::lexer::Span, + ) -> Result { + let Some(JsonValue::Object(_, entries)) = details else { + bail!(span.error("Modify effect requires 'details' to be an object")); + }; + + // Extract roleDefinitionIds and operations from details entries. + let mut role_ids_value: Option<&JsonValue> = None; + let mut operations: Option<&Vec> = None; + + for ObjectEntry { key, value, .. } in entries { + match key.to_lowercase().as_str() { + "roledefinitionids" => role_ids_value = Some(value), + "operations" => { + if let JsonValue::Array(_, ops) = value { + operations = Some(ops); + } else { + bail!(value + .span() + .error("Modify effect 'operations' must be an array")); + } + } + _ => {} // existenceCondition, conflictEffect, etc. — skip + } + } + + // roleDefinitionIds is required for Modify effects (must be an array + // or a template expression that evaluates to one). + let Some(role_json) = role_ids_value else { + bail!(span.error("Modify effect requires 'roleDefinitionIds' in details")); + }; + match role_json { + JsonValue::Array(_, _) => {} + JsonValue::Str(_, s) if crate::languages::azure_policy::parser::is_template_expr(s) => { + } + _ => bail!(role_json.span().error( + "Modify effect 'roleDefinitionIds' must be an array or template expression", + )), + } + + let mut detail_keys: Vec<(u16, u8)> = Vec::new(); + + // roleDefinitionIds — compile as expression (may be parameterized). + { + let role_reg = self.compile_json_value(role_json, role_json.span())?; + let key_idx = self.add_literal_u16(Value::from("roleDefinitionIds"))?; + detail_keys.push((key_idx, role_reg)); + } + + let Some(ops) = operations else { + bail!(span.error("Modify effect requires 'operations' in details")); + }; + if ops.is_empty() { + bail!(span.error("Modify effect 'operations' must not be empty")); + } + + // operations — compile each operation into an object. + { + let mut op_regs = Vec::new(); + for op_json in ops { + let op_reg = self.compile_modify_operation(op_json, span)?; + op_regs.push(op_reg); + } + + let ops_dest = self.alloc_register()?; + let ops_params = ArrayCreateParams { + dest: ops_dest, + elements: op_regs, + }; + let ops_params_index = self + .program + .instruction_data + .add_array_create_params(ops_params); + self.emit( + Instruction::ArrayCreate { + params_index: ops_params_index, + }, + span, + ); + + let key_idx = self.add_literal_u16(Value::from("operations"))?; + detail_keys.push((key_idx, ops_dest)); + } + + let details_dest = build_object_from_keys(self, detail_keys, span)?; + self.wrap_effect_result(effect_name_reg, Some(details_dest), span) + } + + /// Compile a single Modify operation into an object register. + /// + /// Expects `{ "operation": "…", "field": "…", "value": …, "condition": "…" }`. + /// The `"value"` field may contain template expressions. + pub(super) fn compile_modify_operation( + &mut self, + op_json: &JsonValue, + span: &crate::lexer::Span, + ) -> Result { + let JsonValue::Object(_, entries) = op_json else { + bail!(op_json.span().error("modify operation must be an object")); + }; + + let mut op_keys: Vec<(u16, u8)> = Vec::new(); + let mut operation_name: Option = None; + let mut has_field = false; + let mut has_value = false; + + for ObjectEntry { key, value, .. } in entries { + match key.to_lowercase().as_str() { + "operation" => { + let JsonValue::Str(_, op_str) = value else { + bail!(value + .span() + .error("modify operation 'operation' must be a string")); + }; + let canonical_op = match op_str.to_lowercase().as_str() { + "add" => "add", + "addorreplace" => "addOrReplace", + "remove" => "remove", + other => bail!(value + .span() + .error(&format!("unsupported modify operation: {other}"))), + }; + operation_name = Some(canonical_op.into()); + let val = Value::from(canonical_op); + let reg = self.load_literal(val, value.span())?; + let key_idx = self.add_literal_u16(Value::from("operation"))?; + op_keys.push((key_idx, reg)); + } + "field" => { + if let JsonValue::Str(_, field_path) = value { + self.check_modify_field_alias(field_path, value.span())?; + let val = Value::from(field_path.clone()); + let reg = self.load_literal(val, value.span())?; + let key_idx = self.add_literal_u16(Value::from("field"))?; + op_keys.push((key_idx, reg)); + has_field = true; + } else { + bail!(value + .span() + .error("modify operation 'field' must be a string")); + } + } + "value" => { + // Value may contain template expressions. + let reg = self.compile_value_or_expr_from_json(value, value.span())?; + let key_idx = self.add_literal_u16(Value::from("value"))?; + op_keys.push((key_idx, reg)); + has_value = true; + } + "condition" => { + // Condition may contain template expressions. + let reg = self.compile_json_value(value, value.span())?; + let key_idx = self.add_literal_u16(Value::from("condition"))?; + op_keys.push((key_idx, reg)); + } + _ => {} // Unknown fields — skip + } + } + + let Some(op_name) = operation_name else { + bail!(op_json + .span() + .error("modify operation must include 'operation'")); + }; + if !has_field { + bail!(op_json + .span() + .error("modify operation must include 'field'")); + } + // 'add' and 'addOrReplace' require a value; 'remove' does not. + if !has_value && op_name != "remove" { + bail!(op_json.span().error(&format!( + "modify operation '{op_name}' must include 'value'" + ))); + } + + build_object_from_keys(self, op_keys, span) + } + + // -- Append details ----------------------------------------------------- + + /// Compile an Append effect's details. + /// + /// Accepts both array form `[ { "field": …, "value": … }, … ]` and + /// single-object form `{ "field": …, "value": … }`. + pub(super) fn compile_append_details( + &mut self, + effect_name_reg: u8, + details: Option<&JsonValue>, + span: &crate::lexer::Span, + ) -> Result { + let Some(details) = details else { + bail!(span.error("Append effect requires 'details'")); + }; + + let item_regs = match details { + JsonValue::Array(_, arr) => { + if arr.is_empty() { + bail!(span.error("Append effect requires non-empty 'details' array")); + } + let mut regs = Vec::new(); + for item in arr { + regs.push(self.compile_append_item(item, span)?); + } + regs + } + JsonValue::Object(_, _) => { + vec![self.compile_append_item(details, span)?] + } + _ => { + bail!(span.error("Append effect 'details' must be an array or object")); + } + }; + + // Create the details array. + let details_dest = self.alloc_register()?; + let params = ArrayCreateParams { + dest: details_dest, + elements: item_regs, + }; + let params_index = self + .program + .instruction_data + .add_array_create_params(params); + self.emit(Instruction::ArrayCreate { params_index }, span); + + self.wrap_effect_result(effect_name_reg, Some(details_dest), span) + } + + /// Compile a single Append item `{ "field": "…", "value": … }` into an + /// object register. + pub(super) fn compile_append_item( + &mut self, + item_json: &JsonValue, + span: &crate::lexer::Span, + ) -> Result { + let JsonValue::Object(_, entries) = item_json else { + bail!(item_json + .span() + .error("append details item must be an object")); + }; + + let mut field_reg: Option = None; + let mut value_reg: Option = None; + + for ObjectEntry { key, value, .. } in entries { + match key.to_lowercase().as_str() { + "field" => { + let JsonValue::Str(_, field_path) = value else { + bail!(value + .span() + .error("append details item 'field' must be a string")); + }; + let val = Value::from(field_path.clone()); + field_reg = Some(self.load_literal(val, value.span())?); + } + "value" => { + value_reg = Some(self.compile_value_or_expr_from_json(value, value.span())?); + } + _ => {} + } + } + + let Some(field_reg) = field_reg else { + bail!(item_json + .span() + .error("append details item must include 'field'")); + }; + let Some(value_reg) = value_reg else { + bail!(item_json + .span() + .error("append details item must include 'value'")); + }; + + let item_keys = vec![ + (self.add_literal_u16(Value::from("field"))?, field_reg), + (self.add_literal_u16(Value::from("value"))?, value_reg), + ]; + + build_object_from_keys(self, item_keys, span) + } +} diff --git a/src/languages/azure_policy/compiler/expressions.rs b/src/languages/azure_policy/compiler/expressions.rs index 27c3d59..a3c5b78 100644 --- a/src/languages/azure_policy/compiler/expressions.rs +++ b/src/languages/azure_policy/compiler/expressions.rs @@ -4,6 +4,7 @@ //! Template-expression and call-expression compilation. +use alloc::format; use alloc::vec::Vec; use anyhow::{anyhow, bail, Result}; @@ -15,6 +16,9 @@ use crate::Value; use super::core::Compiler; use super::utils::{extract_string_literal, json_value_to_runtime}; +/// Maximum nesting depth for recursive JSON value compilation. +const MAX_JSON_DEPTH: usize = 32; + impl Compiler { pub(super) fn compile_value_or_expr( &mut self, @@ -32,50 +36,82 @@ impl Compiler { value: &crate::languages::azure_policy::ast::JsonValue, span: &crate::lexer::Span, ) -> Result { - // Arrays may contain ARM template expression strings that need - // runtime evaluation. + self.compile_json_value_inner(value, span, 0) + } + + fn compile_json_value_inner( + &mut self, + value: &crate::languages::azure_policy::ast::JsonValue, + span: &crate::lexer::Span, + depth: usize, + ) -> Result { + if depth > MAX_JSON_DEPTH { + bail!(span.error(&format!( + "JSON value nesting exceeds maximum depth of {MAX_JSON_DEPTH}" + ))); + } + + use crate::languages::azure_policy::expr::ExprParser; + 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); + } + } + + // Arrays: recursively compile elements so nested template expressions + // are evaluated at runtime. if let JsonValue::Array(_, items) = value { - if items.iter().any(|item| { - matches!(item, JsonValue::Str(_, s) if crate::languages::azure_policy::parser::is_template_expr(s)) - }) { - return self.compile_dynamic_array(items, span); + if contains_template_expr(value) { + return self.compile_dynamic_array(items, span, depth.saturating_add(1)); } // Fall through: json_value_to_runtime handles `[[` unescaping for // string elements, so static arrays are converted correctly. } + + // Objects: recursively compile values so nested template expressions + // are evaluated at runtime. + if let JsonValue::Object(_, entries) = value { + if contains_template_expr(value) { + return self.compile_dynamic_object(entries, span, depth.saturating_add(1)); + } + } + + // Static value — convert to runtime literal. + // Enforce depth limit on static JSON to prevent stack overflow in + // json_value_to_runtime's own recursion. Use subtree-local depth (0), + // not the compiler recursion depth, since the static subtree's nesting + // is independent of how deep we are in dynamic compilation. + check_json_depth(value, 0).map_err(|_| { + anyhow!(span.error(&alloc::format!( + "JSON value nesting exceeds maximum depth of {MAX_JSON_DEPTH}" + ))) + })?; let runtime_value = json_value_to_runtime(value)?; self.load_literal(runtime_value, span) } - /// Compile a JSON array where some elements are ARM template expressions. + /// Compile a JSON array where some elements may contain template expressions. fn compile_dynamic_array( &mut self, items: &[JsonValue], span: &crate::lexer::Span, + depth: usize, ) -> Result { - use crate::languages::azure_policy::expr::ExprParser; - let mut element_regs = Vec::with_capacity(items.len()); for item in items { - let reg = if let JsonValue::Str(item_span, s) = item { - if crate::languages::azure_policy::parser::is_template_expr(s) { - let inner = s - .strip_prefix('[') - .and_then(|inner| inner.strip_suffix(']')) - .ok_or_else(|| { - item_span.error("invalid template expression: missing brackets") - })?; - let expr = ExprParser::parse_from_brackets(inner, item_span) - .map_err(|e| anyhow!("{}", e))?; - self.compile_expr(&expr)? - } else { - let runtime_value = json_value_to_runtime(item)?; - self.load_literal(runtime_value, item_span)? - } - } else { - let runtime_value = json_value_to_runtime(item)?; - self.load_literal(runtime_value, item.span())? - }; + let reg = self.compile_json_value_inner(item, item.span(), depth)?; element_regs.push(reg); } @@ -95,6 +131,22 @@ impl Compiler { Ok(arr_dest) } + /// Compile a JSON object where some values may contain template expressions. + fn compile_dynamic_object( + &mut self, + entries: &[crate::languages::azure_policy::ast::ObjectEntry], + span: &crate::lexer::Span, + depth: usize, + ) -> Result { + 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 key_idx = self.add_literal_u16(Value::from(entry.key.clone()))?; + keys.push((key_idx, val_reg)); + } + super::effects::build_object_from_keys(self, keys, span) + } + pub(super) fn compile_expr(&mut self, expr: &Expr) -> Result { match expr { Expr::Literal { span, value } => { @@ -315,3 +367,55 @@ impl Compiler { Ok(out) } } + +/// Recursively check whether a JSON value tree contains any template +/// expression strings (e.g. `"[parameters('x')]"`). +/// +/// Returns `false` (conservatively safe) if nesting exceeds [`MAX_JSON_DEPTH`]. +fn contains_template_expr(value: &JsonValue) -> bool { + contains_template_expr_inner(value, 0) +} + +fn contains_template_expr_inner(value: &JsonValue, depth: usize) -> bool { + if depth > MAX_JSON_DEPTH { + return false; + } + + use crate::languages::azure_policy::parser::is_template_expr; + + match value { + JsonValue::Str(_, s) => is_template_expr(s), + JsonValue::Array(_, items) => items + .iter() + .any(|item| contains_template_expr_inner(item, depth.saturating_add(1))), + JsonValue::Object(_, entries) => entries + .iter() + .any(|e| contains_template_expr_inner(&e.value, depth.saturating_add(1))), + _ => false, + } +} + +/// Verify that a JSON value tree does not exceed the maximum nesting depth. +/// +/// Called before handing a static value to [`json_value_to_runtime`] so that +/// its unbounded recursion cannot overflow the stack. Also used by +/// `build_parameter_defaults` to guard parameter default values. +pub(super) fn check_json_depth(value: &JsonValue, current_depth: usize) -> Result<()> { + if current_depth > MAX_JSON_DEPTH { + bail!("JSON value nesting exceeds maximum depth of {MAX_JSON_DEPTH}"); + } + match value { + JsonValue::Array(_, items) => { + for item in items { + check_json_depth(item, current_depth.saturating_add(1))?; + } + } + JsonValue::Object(_, entries) => { + for entry in entries { + check_json_depth(&entry.value, current_depth.saturating_add(1))?; + } + } + _ => {} + } + Ok(()) +} diff --git a/src/languages/azure_policy/compiler/metadata.rs b/src/languages/azure_policy/compiler/metadata.rs index 6af026d..6ba3066 100644 --- a/src/languages/azure_policy/compiler/metadata.rs +++ b/src/languages/azure_policy/compiler/metadata.rs @@ -1,52 +1,284 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -#![allow(dead_code)] +#![allow(clippy::pattern_type_mismatch)] //! Annotation accumulation and metadata population. //! -//! Stub — real implementation added in a later commit. +//! During compilation the compiler records which policy features are used +//! (field kinds, aliases, operators, resource types, etc.). After the +//! main compilation pass, [`populate_compiled_annotations`] writes these +//! observations into the program's metadata so the runtime can inspect +//! them without re-analysing the AST. -use crate::languages::azure_policy::ast::{EffectNode, OperatorKind, PolicyDefinition, PolicyRule}; +use alloc::collections::BTreeSet; +use alloc::string::{String, ToString as _}; + +use crate::languages::azure_policy::ast::{ + Condition, EffectKind, FieldKind, JsonValue, Lhs, OperatorKind, PolicyDefinition, PolicyRule, + ValueOrExpr, +}; +use crate::{Rc, Value}; use super::core::Compiler; impl Compiler { - pub(super) const fn record_field_kind(&mut self, _name: &str) { - _ = self.register_counter; - } - pub(super) const fn record_alias(&mut self, _path: &str) { - _ = self.register_counter; - } - pub(super) const fn record_tag_name(&mut self, _tag: &str) { - _ = self.register_counter; - } - pub(super) const fn record_operator(&mut self, _kind: &OperatorKind) { - _ = self.register_counter; - } - pub(super) const fn record_resource_type_from_condition( - &mut self, - _condition: &crate::languages::azure_policy::ast::Condition, - ) { - _ = self.register_counter; + // -- recording helpers -------------------------------------------------- + + /// Record a built-in field kind reference (e.g. `"type"`, `"location"`). + pub(super) fn record_field_kind(&mut self, name: &str) { + self.observed_field_kinds.insert(name.to_string()); } - #[allow(clippy::unused_self)] - pub(super) fn resolve_effect_annotation(&self, rule: &PolicyRule) -> alloc::string::String { - rule.then_block.effect.raw.clone() + /// Record an alias path reference. Also sets the wildcard flag when the + /// alias contains `[*]`. + pub(super) fn record_alias(&mut self, path: &str) { + self.observed_aliases.insert(path.to_string()); + if path.contains("[*]") { + self.observed_has_wildcard_aliases = true; + } } - #[allow(clippy::unused_self)] - pub(super) fn resolve_effect_kind( - &self, - effect: &EffectNode, - ) -> crate::languages::azure_policy::ast::EffectKind { - effect.kind.clone() + /// Record a tag name reference (e.g. `"environment"` from `tags.environment`). + pub(super) fn record_tag_name(&mut self, tag: &str) { + self.observed_tag_names.insert(tag.to_string()); } - pub(super) const fn populate_compiled_annotations(&mut self) { - _ = self.register_counter; + /// Record an operator usage, mapping the `OperatorKind` to its + /// canonical JSON name (e.g. `Equals` → `"equals"`). + pub(super) fn record_operator(&mut self, kind: &OperatorKind) { + let name = match kind { + OperatorKind::Equals => "equals", + OperatorKind::NotEquals => "notEquals", + OperatorKind::Greater => "greater", + OperatorKind::GreaterOrEquals => "greaterOrEquals", + OperatorKind::Less => "less", + OperatorKind::LessOrEquals => "lessOrEquals", + OperatorKind::In => "in", + OperatorKind::NotIn => "notIn", + OperatorKind::Contains => "contains", + OperatorKind::NotContains => "notContains", + OperatorKind::ContainsKey => "containsKey", + OperatorKind::NotContainsKey => "notContainsKey", + OperatorKind::Like => "like", + OperatorKind::NotLike => "notLike", + OperatorKind::Match => "match", + OperatorKind::NotMatch => "notMatch", + OperatorKind::MatchInsensitively => "matchInsensitively", + OperatorKind::NotMatchInsensitively => "notMatchInsensitively", + OperatorKind::Exists => "exists", + }; + self.observed_operators.insert(name.to_string()); } - pub(super) const fn populate_definition_metadata(&mut self, _defn: &PolicyDefinition) { - _ = self.register_counter; + + /// Extract resource type strings from `{ "field": "type", "equals"/"in": … }` + /// conditions and record them for metadata. + pub(super) fn record_resource_type_from_condition(&mut self, condition: &Condition) { + let is_type_field = + matches!(&condition.lhs, Lhs::Field(f) if matches!(f.kind, FieldKind::Type)); + if !is_type_field { + return; + } + + match &condition.operator.kind { + // Positive operators — record types the policy applies to. + OperatorKind::Equals => { + if let ValueOrExpr::Value(JsonValue::Str(_, s)) = &condition.rhs { + self.observed_resource_types.insert(s.clone()); + } + } + OperatorKind::In => match &condition.rhs { + ValueOrExpr::Value(JsonValue::Array(_, items)) => { + for item in items { + if let JsonValue::Str(_, s) = item { + self.observed_resource_types.insert(s.clone()); + } + } + } + ValueOrExpr::Value(JsonValue::Str(_, s)) => { + self.observed_resource_types.insert(s.clone()); + } + _ => {} + }, + OperatorKind::Like => match &condition.rhs { + ValueOrExpr::Value(JsonValue::Str(_, s)) => { + self.observed_resource_types.insert(s.clone()); + } + ValueOrExpr::Value(JsonValue::Array(_, items)) => { + for item in items { + if let JsonValue::Str(_, s) = item { + self.observed_resource_types.insert(s.clone()); + } + } + } + _ => {} + }, + OperatorKind::Contains => { + if let ValueOrExpr::Value(JsonValue::Str(_, s)) = &condition.rhs { + self.observed_resource_types.insert(s.clone()); + } + } + // Negative operators (NotEquals, NotIn, NotLike, NotContains) are + // intentionally excluded — they indicate types the policy does NOT + // apply to, which is not the same as applicability. + _ => {} + } + } + + // -- effect annotation -------------------------------------------------- + + /// Build the effect annotation string, resolving parameterized effects + /// to their default values when possible. + pub(super) fn resolve_effect_annotation(&self, rule: &PolicyRule) -> String { + let effect = &rule.then_block.effect; + match &effect.kind { + EffectKind::Other => self + .resolve_effect_name_from_parameter_default(effect) + .unwrap_or_else(|| effect.raw.clone()), + _ => effect.raw.clone(), + } + } + + // -- annotation population ---------------------------------------------- + + /// Populate `program.metadata.annotations` from accumulated observations. + /// + /// Called once after the main compilation pass to write all recorded + /// features into the program metadata. + pub(super) fn populate_compiled_annotations(&mut self) { + // Read has_host_await before borrowing annotations mutably. + let has_host_await = self.program.has_host_await(); + let annot = &mut self.program.metadata.annotations; + + // Observed string sets → annotation sets. + insert_string_set_annotation(annot, "field_kinds", &self.observed_field_kinds); + insert_string_set_annotation(annot, "aliases", &self.observed_aliases); + insert_string_set_annotation(annot, "tag_names", &self.observed_tag_names); + insert_string_set_annotation(annot, "operators", &self.observed_operators); + insert_string_set_annotation(annot, "resource_types", &self.observed_resource_types); + + // Boolean flags. + if self.observed_uses_count { + annot.insert("uses_count".to_string(), Value::Bool(true)); + } + if self.observed_has_dynamic_fields { + annot.insert("has_dynamic_fields".to_string(), Value::Bool(true)); + } + if self.observed_has_wildcard_aliases { + annot.insert("has_wildcard_aliases".to_string(), Value::Bool(true)); + } + if has_host_await { + annot.insert("has_host_await".to_string(), Value::Bool(true)); + } + } + + /// Set definition-level metadata (display name, description, category, + /// parameter names, etc.) from a `PolicyDefinition`. + pub(super) fn populate_definition_metadata(&mut self, defn: &PolicyDefinition) { + let annot = &mut self.program.metadata.annotations; + + // Top-level definition fields. + if let Some(ref name) = defn.display_name { + annot.insert( + "display_name".to_string(), + Value::String(name.as_str().into()), + ); + } + if let Some(ref desc) = defn.description { + annot.insert( + "description".to_string(), + Value::String(desc.as_str().into()), + ); + } + if let Some(ref mode) = defn.mode { + annot.insert("mode".to_string(), Value::String(mode.as_str().into())); + } + + // Extract category, version, and preview from metadata JSON. + if let Some(JsonValue::Object(_, entries)) = defn.metadata.as_ref() { + for entry in entries { + match entry.key.to_lowercase().as_str() { + "category" => { + if let JsonValue::Str(_, ref s) = entry.value { + annot.insert("category".to_string(), Value::String(s.as_str().into())); + } + } + "version" => { + if let JsonValue::Str(_, ref s) = entry.value { + annot.insert("version".to_string(), Value::String(s.as_str().into())); + } + } + "preview" => { + if let JsonValue::Bool(_, b) = entry.value { + annot.insert("preview".to_string(), Value::Bool(b)); + } + } + "deprecated" => { + if let JsonValue::Bool(_, b) = entry.value { + annot.insert("deprecated".to_string(), Value::Bool(b)); + } + } + "portalreview" => { + if let JsonValue::Str(_, ref s) = entry.value { + annot.insert( + "portal_review".to_string(), + Value::String(s.as_str().into()), + ); + } + } + _ => {} + } + } + } + + // Parameter names. + if !defn.parameters.is_empty() { + let set: BTreeSet = defn + .parameters + .iter() + .map(|p| Value::String(p.name.as_str().into())) + .collect(); + annot.insert("parameter_names".to_string(), Value::Set(Rc::new(set))); + } + + // Extra fields: policyType → policy_type, id → policy_id, name → policy_name. + for entry in &defn.extra { + match entry.key.to_lowercase().as_str() { + "policytype" => { + if let JsonValue::Str(_, ref s) = entry.value { + annot.insert("policy_type".to_string(), Value::String(s.as_str().into())); + } + } + "id" => { + if let JsonValue::Str(_, ref s) = entry.value { + annot.insert("policy_id".to_string(), Value::String(s.as_str().into())); + } + } + "name" => { + if let JsonValue::Str(_, ref s) = entry.value { + annot.insert("policy_name".to_string(), Value::String(s.as_str().into())); + } + } + _ => {} + } + } + } +} + +// --------------------------------------------------------------------------- +// Module-private helpers +// --------------------------------------------------------------------------- + +/// Insert a non-empty `BTreeSet` as a `Value::Set` annotation. +fn insert_string_set_annotation( + annot: &mut alloc::collections::BTreeMap, + key: &str, + observed: &BTreeSet, +) { + if !observed.is_empty() { + let set: BTreeSet = observed + .iter() + .map(|s| Value::String(s.as_str().into())) + .collect(); + annot.insert(key.to_string(), Value::Set(Rc::new(set))); } } diff --git a/src/languages/azure_policy/compiler/mod.rs b/src/languages/azure_policy/compiler/mod.rs index e407c66..01cde81 100644 --- a/src/languages/azure_policy/compiler/mod.rs +++ b/src/languages/azure_policy/compiler/mod.rs @@ -138,12 +138,22 @@ pub fn compile_policy_definition_with_aliases_opts( fn build_parameter_defaults( params: &[crate::languages::azure_policy::ast::ParameterDefinition], ) -> Result { + use crate::languages::azure_policy::compiler::expressions::check_json_depth; use crate::languages::azure_policy::compiler::utils::json_value_to_runtime; + use alloc::format; + use anyhow::Context as _; let mut obj = Value::new_object(); let map = obj.as_object_mut()?; for param in params { if let Some(ref default_val) = param.default_value { - let runtime_val = json_value_to_runtime(default_val)?; + check_json_depth(default_val, 0).with_context(|| { + format!( + "invalid defaultValue for parameter '{}': exceeds maximum JSON depth", + param.name + ) + })?; + let runtime_val = json_value_to_runtime(default_val) + .with_context(|| format!("invalid defaultValue for parameter '{}'", param.name))?; map.insert(Value::from(param.name.clone()), runtime_val); } }