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); } }