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

Adds the YAML test runner that exercises the companion test data PRs, plus
several compiler fixes surfaced during testing:

- Removed parameter register caching that produced wrong results inside
  short-circuiting allOf/anyOf blocks; added literal-index caching for
  parameter defaults to avoid repeated O(n) literal-table scans
- Simplified cross-resource effect details to only emit roleDefinitionIds
  and type (deployment templates are not evaluated for compliance)
- Replaced guid/uniqueString builtins with clear "unsupported" errors
- Normalized datetime output to ISO 8601 with Z suffix
- Added azure_policy parser MAX_COL constant (8192) for long template
  expressions, keeping the global DEFAULT_MAX_COL at 1024
- Added rvm to azure_policy feature dependencies since the compiler
  targets RVM bytecode

Also restructures the example binary into examples/regorus/ with new
azure-policy-eval and azure-policy-aliases subcommands, adds C# alias
normalization tests, and documents Azure Policy support in the README.

Signed-off-by: Anand Krishnamoorthi <anakrish@microsoft.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Anand Krishnamoorthi
2026-04-30 13:02:37 -05:00
committed by GitHub
parent 7f42115b63
commit 4c92fb4d92
24 changed files with 1555 additions and 191 deletions

View File

@@ -24,7 +24,7 @@ default = ["full-opa", "arc", "rvm"]
arc = [] arc = []
ast = [] ast = []
azure_policy = ["dep:jsonschema", "dep:chrono", "dep:ipnet", "dep:icu_casemap", "dep:hashbrown", "arc", "dashmap"] azure_policy = ["dep:jsonschema", "dep:chrono", "dep:ipnet", "dep:icu_casemap", "dep:hashbrown", "arc", "dashmap", "rvm"]
azure-rbac = ["regex", "time", "net"] azure-rbac = ["regex", "time", "net"]
base64 = ["dep:data-encoding"] base64 = ["dep:data-encoding"]
base64url = ["dep:data-encoding"] base64url = ["dep:data-encoding"]

View File

@@ -129,7 +129,7 @@ It is straight-forward to build these bindings yourself.
## Getting Started ## Getting Started
[examples/regorus](https://github.com/microsoft/regorus/blob/main/examples/regorus.rs) is an example program that [examples/regorus](https://github.com/microsoft/regorus/blob/main/examples/regorus/main.rs) is an example program that
shows how to integrate Regorus into your project and evaluate Rego policies. shows how to integrate Regorus into your project and evaluate Rego policies.
To build and install it, do To build and install it, do
@@ -248,6 +248,52 @@ $ diff <(regorus eval -b tests/aci -d tests/aci/data.json -i tests/aci/input.jso
``` ```
## Azure Policy (Preview)
Regorus can evaluate [Azure Policy](https://learn.microsoft.com/en-us/azure/governance/policy/overview)
definitions natively. A dedicated compiler translates Azure Policy JSON
directly into RVM (Regorus Virtual Machine) bytecode — the same VM that
powers Rego evaluation — so you don't have to rewrite policies in Rego.
Enable it with the `azure_policy` cargo feature.
Most of the policy language is supported: conditions with `field`, `count`,
and `value`; logical connectives (`allOf`, `anyOf`, `not`); comparison
operators; template expressions like `parameters()`, `concat()`,
`dateTimeAdd()`, and `utcNow()`; and effects including Deny, Audit, Modify,
Append, AuditIfNotExists, and DeployIfNotExists. An alias registry handles
the translation from fully-qualified alias names to the flattened ARM resource
shape expected by the engine.
### Quick start
```bash
cargo install --example regorus --features azure_policy --path .
# Evaluate a policy against a non-compliant storage account (→ Deny)
regorus azure-policy-eval \
--policy-definition examples/regorus/azure_policy_data/require_https_storage.json \
--resource examples/regorus/azure_policy_data/non_compliant_storage.json \
--aliases tests/azure_policy/aliases/test_aliases.json
# Same policy against a compliant resource (→ undefined, no effect)
regorus azure-policy-eval \
--policy-definition examples/regorus/azure_policy_data/require_https_storage.json \
--resource examples/regorus/azure_policy_data/compliant_storage.json \
--aliases tests/azure_policy/aliases/test_aliases.json
# List aliases for a resource type
regorus azure-policy-aliases \
--aliases tests/azure_policy/aliases/test_aliases.json \
--resource-type Microsoft.Storage
```
The test suite covers conditions, effects, template functions, alias
resolution, and end-to-end scenarios across YAML-driven test files:
```bash
cargo test --features azure_policy -- azure_policy
```
## Performance ## Performance
To check how fast Regorus runs on your system, first install a tool like [hyperfine](https://github.com/sharkdp/hyperfine). To check how fast Regorus runs on your system, first install a tool like [hyperfine](https://github.com/sharkdp/hyperfine).

View File

@@ -0,0 +1,187 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
using System;
using System.IO;
using System.Text.Json.Nodes;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Regorus;
namespace Regorus.Tests;
/// <summary>
/// Tests for Azure Policy alias normalization and denormalization
/// using the AliasRegistry exposed through the C# bindings.
/// </summary>
[TestClass]
public class AzurePolicyTests
{
/// <summary>
/// Sample alias definitions for Microsoft.Storage provider.
/// These mirror a subset of the test aliases used by the Rust test suite.
/// </summary>
private const string StorageAliasesJson = @"[{
""namespace"": ""Microsoft.Storage"",
""resourceTypes"": [{
""resourceType"": ""storageAccounts"",
""capabilities"": ""SupportsTags, SupportsLocation"",
""aliases"": [
{
""name"": ""Microsoft.Storage/storageAccounts/supportsHttpsTrafficOnly"",
""defaultPath"": ""properties.supportsHttpsTrafficOnly"",
""paths"": []
},
{
""name"": ""Microsoft.Storage/storageAccounts/minimumTlsVersion"",
""defaultPath"": ""properties.minimumTlsVersion"",
""paths"": []
},
{
""name"": ""Microsoft.Storage/storageAccounts/allowBlobPublicAccess"",
""defaultPath"": ""properties.allowBlobPublicAccess"",
""paths"": []
}
]
}]
}]";
/// <summary>
/// ARM resource in its original shape (with properties wrapper).
/// </summary>
private const string StorageResourceJson = @"{
""type"": ""Microsoft.Storage/storageAccounts"",
""name"": ""mystorage"",
""location"": ""eastus"",
""properties"": {
""supportsHttpsTrafficOnly"": true,
""minimumTlsVersion"": ""TLS1_2"",
""allowBlobPublicAccess"": false
}
}";
[TestMethod]
public void AliasRegistry_NormalizeAndWrap_produces_input_envelope()
{
using var registry = new AliasRegistry();
registry.LoadJson(StorageAliasesJson);
var result = registry.NormalizeAndWrap(
StorageResourceJson,
apiVersion: null,
contextJson: "{}",
parametersJson: "{}");
Assert.IsNotNull(result, "NormalizeAndWrap should return a non-null string");
// The result should be valid JSON with resource, parameters, and context keys.
var doc = JsonNode.Parse(result);
Assert.IsNotNull(doc);
Assert.IsNotNull(doc["resource"], "envelope must contain 'resource'");
Assert.IsNotNull(doc["parameters"], "envelope must contain 'parameters'");
Assert.IsNotNull(doc["context"], "envelope must contain 'context'");
}
[TestMethod]
public void AliasRegistry_NormalizeAndWrap_flattens_properties()
{
using var registry = new AliasRegistry();
registry.LoadJson(StorageAliasesJson);
var result = registry.NormalizeAndWrap(StorageResourceJson);
Assert.IsNotNull(result);
var doc = JsonNode.Parse(result);
var resource = doc!["resource"];
Assert.IsNotNull(resource);
// After normalization, alias-mapped properties should be
// available at the top level of the resource (lowercased).
// The normalizer flattens "properties.supportsHttpsTrafficOnly"
// to "supportshttpstrafficonly" at the resource root.
var httpsOnly = resource["supportshttpstrafficonly"];
Assert.IsNotNull(httpsOnly,
"normalized resource should have 'supportshttpstrafficonly' at top level");
Assert.AreEqual(true, httpsOnly!.GetValue<bool>());
}
[TestMethod]
public void AliasRegistry_NormalizeAndWrap_preserves_type_field()
{
using var registry = new AliasRegistry();
registry.LoadJson(StorageAliasesJson);
var result = registry.NormalizeAndWrap(StorageResourceJson);
var doc = JsonNode.Parse(result!);
var resource = doc!["resource"];
// The "type" field should be preserved (lowercased key).
var typeField = resource!["type"];
Assert.IsNotNull(typeField, "normalized resource should have 'type'");
Assert.AreEqual(
"microsoft.storage/storageaccounts",
typeField!.GetValue<string>().ToLowerInvariant());
}
[TestMethod]
public void AliasRegistry_NormalizeAndWrap_includes_parameters()
{
using var registry = new AliasRegistry();
registry.LoadJson(StorageAliasesJson);
var parametersJson = @"{ ""effect"": ""Deny"" }";
var result = registry.NormalizeAndWrap(
StorageResourceJson,
parametersJson: parametersJson);
Assert.IsNotNull(result);
var doc = JsonNode.Parse(result!);
var parameters = doc!["parameters"];
Assert.IsNotNull(parameters);
Assert.AreEqual("Deny", parameters!["effect"]!.GetValue<string>());
}
[TestMethod]
public void AliasRegistry_Denormalize_roundtrips_correctly()
{
using var registry = new AliasRegistry();
registry.LoadJson(StorageAliasesJson);
// Normalize the ARM resource.
var envelope = registry.NormalizeAndWrap(StorageResourceJson);
Assert.IsNotNull(envelope);
// Extract just the normalized resource from the envelope.
var doc = JsonNode.Parse(envelope!);
var normalizedResource = doc!["resource"]!.ToJsonString();
// Denormalize back to ARM shape.
var denormalized = registry.Denormalize(normalizedResource);
Assert.IsNotNull(denormalized, "Denormalize should return a non-null string");
// The denormalized result should have a "properties" wrapper again.
var denormDoc = JsonNode.Parse(denormalized!);
Assert.IsNotNull(denormDoc);
var props = denormDoc!["properties"];
Assert.IsNotNull(props, "denormalized resource should have 'properties'");
}
[TestMethod]
public void AliasRegistry_loads_test_aliases_file()
{
// Load the same aliases file used by the Rust test suite.
var aliasesPath = Path.Combine(AppContext.BaseDirectory, "tests", "azure_policy", "aliases", "test_aliases.json");
if (!File.Exists(aliasesPath))
{
Assert.Inconclusive($"Test aliases file not found at {aliasesPath}");
return;
}
var aliasesJson = File.ReadAllText(aliasesPath);
using var registry = new AliasRegistry();
registry.LoadJson(aliasesJson);
// The test_aliases.json file contains multiple providers.
Assert.IsTrue(registry.Length > 0,
"registry should have loaded at least one resource type");
}
}

View File

@@ -0,0 +1,156 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
//! Azure Policy evaluation subcommand for the regorus example binary.
//!
//! Demonstrates parsing an Azure Policy definition JSON, compiling it to
//! RVM bytecode, normalizing an ARM resource through the alias registry,
//! and evaluating the compiled policy against the normalized input.
//!
//! Usage:
//! cargo run --example regorus --features azure_policy -- \
//! azure-policy-eval \
//! --policy-definition policy.json \
//! --resource resource.json \
//! --aliases aliases.json \
//! [--parameters '{"sku": "Standard_D2s_v3"}'] \
//! [--api-version 2023-01-01]
use anyhow::{bail, Result};
use regorus::languages::azure_policy::aliases::normalizer;
use regorus::languages::azure_policy::aliases::AliasRegistry;
use regorus::languages::azure_policy::compiler;
use regorus::languages::azure_policy::parser;
use regorus::rvm::RegoVM;
use regorus::Source;
use regorus::Value;
/// Evaluate an Azure Policy definition against a resource.
///
/// This mirrors the pipeline used in production:
/// 1. Load aliases and build the alias registry
/// 2. Parse the policy definition JSON
/// 3. Compile to RVM bytecode (with alias-aware field resolution)
/// 4. Normalize the ARM resource through the alias registry
/// 5. Run the compiled program in the Rego VM
pub fn azure_policy_eval(
policy_definition: String,
resource: String,
aliases: String,
parameters_json: Option<String>,
api_version: Option<String>,
) -> Result<()> {
// 1. Load alias registry.
let aliases_json = std::fs::read_to_string(&aliases)
.map_err(|e| anyhow::anyhow!("failed to read aliases file {aliases}: {e}"))?;
let mut registry = AliasRegistry::new();
registry.load_from_json(&aliases_json)?;
println!(
"Loaded {} resource type(s) from alias registry",
registry.len()
);
// 2. Parse the policy definition.
let defn_json = std::fs::read_to_string(&policy_definition)
.map_err(|e| anyhow::anyhow!("failed to read policy file {policy_definition}: {e}"))?;
let source = Source::from_contents(policy_definition.clone(), defn_json)?;
let defn = parser::parse_policy_definition(&source)
.map_err(|e| anyhow::anyhow!("parse error: {e}"))?;
println!("Parsed policy definition from {policy_definition}");
// 3. Compile to RVM bytecode.
let program = compiler::compile_policy_definition_with_aliases(
&defn,
registry.alias_map(),
registry.alias_modifiable_map(),
)?;
println!("Compiled policy to RVM bytecode");
// 4. Build normalized input.
let resource_json = std::fs::read_to_string(&resource)
.map_err(|e| anyhow::anyhow!("failed to read resource file {resource}: {e}"))?;
let raw_resource = Value::from_json_str(&resource_json)?;
let normalized = normalizer::normalize(&raw_resource, Some(&registry), api_version.as_deref());
println!("Normalized resource ({} top-level fields)", {
normalized.as_object().map(|m| m.len()).unwrap_or(0)
});
// Inject api_version into the normalized resource (lowercased key to match
// the host contract — policies reference `field('apiVersion')` which the
// compiler lowercases to `apiversion`).
let mut resource = normalized;
if let Some(ref api_ver) = api_version {
let map = resource.as_object_mut()?;
map.insert(Value::from("apiversion"), Value::from(api_ver.clone()));
}
// Build the input envelope: { resource, parameters }
let parameters = if let Some(ref params) = parameters_json {
Value::from_json_str(params)?
} else {
Value::new_object()
};
let mut input = Value::new_object();
{
let map = input.as_object_mut()?;
map.insert(Value::from("resource"), resource);
map.insert(Value::from("parameters"), parameters);
}
// Build a default context with requestContext if api_version is provided.
let mut context = Value::from_json_str(
r#"{
"resourceGroup": { "name": "exampleRG", "location": "eastus" },
"subscription": { "subscriptionId": "00000000-0000-0000-0000-000000000000" }
}"#,
)?;
if let Some(ref api_ver) = api_version {
let mut req_ctx = Value::new_object();
let rc_map = req_ctx.as_object_mut()?;
rc_map.insert(Value::from("apiVersion"), Value::from(api_ver.clone()));
let ctx_map = context.as_object_mut()?;
ctx_map.insert(Value::from("requestContext"), req_ctx);
}
// 5. Execute in the Rego VM.
let mut vm = RegoVM::new();
vm.load_program(program);
vm.set_input(input);
vm.set_context(context);
let result = vm.execute_entry_point_by_name("main")?;
println!("\nPolicy evaluation result:");
println!("{}", serde_json::to_string_pretty(&result)?);
Ok(())
}
/// List available aliases for a resource type.
pub fn azure_policy_aliases(aliases: String, resource_type: Option<String>) -> Result<()> {
let aliases_json = std::fs::read_to_string(&aliases)
.map_err(|e| anyhow::anyhow!("failed to read aliases file {aliases}: {e}"))?;
let mut registry = AliasRegistry::new();
registry.load_from_json(&aliases_json)?;
println!("Alias registry: {} resource type(s)", registry.len());
if let Some(ref rt) = resource_type {
let rt_lower = rt.to_lowercase();
let mut found = false;
for (alias_name, _) in registry.alias_map() {
if alias_name.to_lowercase().starts_with(&rt_lower) {
println!(" {alias_name}");
found = true;
}
}
if !found {
bail!("no aliases found for resource type '{rt}'");
}
} else {
for (alias_name, _) in registry.alias_map() {
println!(" {alias_name}");
}
}
Ok(())
}

View File

@@ -0,0 +1,15 @@
{
"type": "Microsoft.Storage/storageAccounts",
"name": "securestorageaccount",
"location": "eastus",
"kind": "StorageV2",
"properties": {
"supportsHttpsTrafficOnly": true,
"minimumTlsVersion": "TLS1_2",
"encryption": {
"services": {
"blob": { "enabled": true }
}
}
}
}

View File

@@ -0,0 +1,15 @@
{
"type": "Microsoft.Storage/storageAccounts",
"name": "mystorageaccount",
"location": "eastus",
"kind": "StorageV2",
"properties": {
"supportsHttpsTrafficOnly": false,
"minimumTlsVersion": "TLS1_0",
"encryption": {
"services": {
"blob": { "enabled": true }
}
}
}
}

View File

@@ -0,0 +1,36 @@
{
"properties": {
"displayName": "Require HTTPS for Storage Accounts",
"description": "Denies storage accounts that do not have HTTPS traffic only enabled.",
"policyType": "Custom",
"mode": "All",
"parameters": {
"effect": {
"type": "String",
"metadata": {
"displayName": "Effect",
"description": "Enable or disable the execution of the policy"
},
"allowedValues": ["Deny", "Audit", "Disabled"],
"defaultValue": "Deny"
}
},
"policyRule": {
"if": {
"allOf": [
{
"field": "type",
"equals": "Microsoft.Storage/storageAccounts"
},
{
"field": "Microsoft.Storage/storageAccounts/supportsHttpsTrafficOnly",
"notEquals": true
}
]
},
"then": {
"effect": "[parameters('effect')]"
}
}
}
}

View File

@@ -3,6 +3,9 @@
use anyhow::{anyhow, bail, Result}; use anyhow::{anyhow, bail, Result};
#[cfg(feature = "azure_policy")]
mod azure_policy;
#[allow(dead_code)] #[allow(dead_code)]
fn read_file(path: &String) -> Result<String> { fn read_file(path: &String) -> Result<String> {
std::fs::read_to_string(path).map_err(|_| anyhow!("could not read {path}")) std::fs::read_to_string(path).map_err(|_| anyhow!("could not read {path}"))
@@ -267,6 +270,42 @@ enum RegorusCommand {
#[arg(long)] #[arg(long)]
v0: bool, v0: bool,
}, },
/// Evaluate an Azure Policy definition against a resource.
#[cfg(feature = "azure_policy")]
AzurePolicyEval {
/// Azure Policy definition JSON file.
#[arg(long)]
policy_definition: String,
/// ARM resource JSON file to evaluate.
#[arg(long)]
resource: String,
/// Aliases JSON file (provider aliases).
#[arg(long)]
aliases: String,
/// Policy parameters as a JSON string.
#[arg(long)]
parameters: Option<String>,
/// API version for alias path selection.
#[arg(long)]
api_version: Option<String>,
},
/// List aliases from an alias registry file.
#[cfg(feature = "azure_policy")]
AzurePolicyAliases {
/// Aliases JSON file (provider aliases).
#[arg(long)]
aliases: String,
/// Filter aliases by resource type prefix.
#[arg(long)]
resource_type: Option<String>,
},
} }
#[derive(clap::Parser)] #[derive(clap::Parser)]
@@ -306,5 +345,24 @@ fn main() -> Result<()> {
RegorusCommand::Lex { file, verbose } => rego_lex(file, verbose), RegorusCommand::Lex { file, verbose } => rego_lex(file, verbose),
RegorusCommand::Parse { file, v0 } => rego_parse(file, v0), RegorusCommand::Parse { file, v0 } => rego_parse(file, v0),
RegorusCommand::Ast { file } => rego_ast(file), RegorusCommand::Ast { file } => rego_ast(file),
#[cfg(feature = "azure_policy")]
RegorusCommand::AzurePolicyEval {
policy_definition,
resource,
aliases,
parameters,
api_version,
} => azure_policy::azure_policy_eval(
policy_definition,
resource,
aliases,
parameters,
api_version,
),
#[cfg(feature = "azure_policy")]
RegorusCommand::AzurePolicyAliases {
aliases,
resource_type,
} => azure_policy::azure_policy_aliases(aliases, resource_type),
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -43,6 +43,13 @@ use super::expr::ExprParser;
use self::core::Parser; use self::core::Parser;
/// Column-width limit for Azure Policy definitions.
///
/// Azure Policy definitions are often serialized as single-line JSON with
/// deeply nested template expressions, requiring a much higher limit than
/// the standard Rego default (1024).
pub const MAX_COL: u32 = Parser::MAX_COL;
// ============================================================================ // ============================================================================
// Public API // Public API
// ============================================================================ // ============================================================================
@@ -62,12 +69,14 @@ pub fn parse_policy_rule(source: &Source) -> Result<PolicyRule, ParseError> {
parse_policy_rule_with_max_col(source, None) parse_policy_rule_with_max_col(source, None)
} }
/// Like [`parse_policy_rule`] but with an optional column-width override. /// Like [`parse_policy_rule`] but with an explicit column-width override.
///
/// When `max_col` is `None`, uses [`MAX_COL`] (the Azure Policy default).
pub fn parse_policy_rule_with_max_col( pub fn parse_policy_rule_with_max_col(
source: &Source, source: &Source,
max_col: Option<NonZeroU32>, max_col: Option<NonZeroU32>,
) -> Result<PolicyRule, ParseError> { ) -> Result<PolicyRule, ParseError> {
let mut parser = Parser::new_with_max_col(source, max_col)?; let mut parser = Parser::new_with_max_col(source, max_col.or(NonZeroU32::new(MAX_COL)))?;
let rule = parser.parse_policy_rule()?; let rule = parser.parse_policy_rule()?;
if parser.tok.0 != TokenKind::Eof { if parser.tok.0 != TokenKind::Eof {
@@ -92,12 +101,14 @@ pub fn parse_policy_definition(source: &Source) -> Result<PolicyDefinition, Pars
parse_policy_definition_with_max_col(source, None) parse_policy_definition_with_max_col(source, None)
} }
/// Like [`parse_policy_definition`] but with an optional column-width override. /// Like [`parse_policy_definition`] but with an explicit column-width override.
///
/// When `max_col` is `None`, uses [`MAX_COL`] (the Azure Policy default).
pub fn parse_policy_definition_with_max_col( pub fn parse_policy_definition_with_max_col(
source: &Source, source: &Source,
max_col: Option<NonZeroU32>, max_col: Option<NonZeroU32>,
) -> Result<PolicyDefinition, ParseError> { ) -> Result<PolicyDefinition, ParseError> {
let mut parser = Parser::new_with_max_col(source, max_col)?; let mut parser = Parser::new_with_max_col(source, max_col.or(NonZeroU32::new(MAX_COL)))?;
let defn = parser.parse_policy_definition()?; let defn = parser.parse_policy_definition()?;
if parser.tok.0 != TokenKind::Eof { if parser.tok.0 != TokenKind::Eof {

View File

@@ -537,3 +537,73 @@ cases:
resource: resource:
type: "Microsoft.Storage/storageAccounts" type: "Microsoft.Storage/storageAccounts"
want_undefined: true want_undefined: true
# =========================================================================
# Bare effects — no details provided
# =========================================================================
- note: append_bare_effect_no_details
policy_rule: |
{
"if": {
"field": "type",
"equals": "Microsoft.Storage/storageAccounts"
},
"then": {
"effect": "append"
}
}
resource:
type: "Microsoft.Storage/storageAccounts"
want_effect: "append"
- note: modify_bare_effect_no_details
policy_rule: |
{
"if": {
"field": "type",
"equals": "Microsoft.Storage/storageAccounts"
},
"then": {
"effect": "modify"
}
}
resource:
type: "Microsoft.Storage/storageAccounts"
want_effect: "modify"
# =========================================================================
# Parameterized cross-resource details.type (template expression)
# =========================================================================
- note: parameterized_cross_resource_type
policy_definition: |
{
"properties": {
"parameters": {
"resourceType": {
"type": "String",
"defaultValue": "Microsoft.Insights/diagnosticSettings"
}
},
"policyRule": {
"if": {
"field": "type",
"equals": "Microsoft.Compute/virtualMachines"
},
"then": {
"effect": "auditIfNotExists",
"details": {
"type": "[parameters('resourceType')]"
}
}
}
}
}
resource:
type: "Microsoft.Compute/virtualMachines"
host_await:
- response: null
want_effect: "auditIfNotExists"
want_details:
type: "Microsoft.Insights/diagnosticSettings"

View File

@@ -1,9 +1,10 @@
# Copyright (c) Microsoft Corporation. # Copyright (c) Microsoft Corporation.
# Licensed under the MIT License. # Licensed under the MIT License.
# Parse Error Test Suite # Parse Error and Edge Case Test Suite
# Tests that malformed policy JSON and invalid constructs are properly rejected. # Tests mostly malformed policy JSON and invalid constructs to ensure they are
# These test cases are expected to fail parsing. # properly rejected, but also includes valid edge cases that verify parser
# behavior at boundary conditions.
cases: cases:
# ========================================================================= # =========================================================================
@@ -267,3 +268,33 @@ cases:
resource: resource:
type: "any" type: "any"
want_effect: "deny" want_effect: "deny"
# =========================================================================
# Compile errors: unsupported deployment-template functions
# =========================================================================
- note: guid_compile_error
policy_rule: |
{
"if": {
"value": "[guid('baseString')]",
"equals": "anything"
},
"then": { "effect": "deny" }
}
resource:
type: "any"
want_compile_error: true
- note: uniquestring_compile_error
policy_rule: |
{
"if": {
"value": "[uniqueString('baseString')]",
"equals": "anything"
},
"then": { "effect": "deny" }
}
resource:
type: "any"
want_compile_error: true

View File

@@ -1,5 +1,705 @@
// Copyright (c) Microsoft Corporation. // Copyright (c) Microsoft Corporation.
// Licensed under the MIT License. // Licensed under the MIT License.
//! YAML-driven test suite for Azure Policy parsing, compilation, and evaluation.
//!
//! Each YAML file in `tests/azure_policy/cases/` contains a list of test cases.
//! Each case specifies a policy rule JSON string plus the expected parse and
//! evaluation outcomes.
//!
//! The test runner validates:
//! - Successful parsing of policy rule JSON into the AST
//! - Expected parse failures for malformed inputs
//! - Compilation of the AST to RVM bytecode
//! - Evaluation of the compiled policy against the provided resource/parameters
//! - Expected `want_effect` / `want_undefined` results
mod normalization; mod normalization;
mod parser_tests; mod parser_tests;
use anyhow::{bail, Result};
use regorus::languages::azure_policy::aliases::normalizer;
use regorus::languages::azure_policy::aliases::AliasRegistry;
use regorus::languages::azure_policy::compiler;
use regorus::languages::azure_policy::parser;
use regorus::rvm::RegoVM;
use regorus::Source;
use regorus::Value;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::fs;
use std::path::Path;
use test_generator::test_resources;
/// A single test case in the YAML file.
#[derive(Serialize, Deserialize, Debug)]
#[serde(deny_unknown_fields)]
struct TestCase {
/// Short identifier for the test case.
pub note: String,
/// The Azure Policy `policyRule` JSON string.
#[serde(default)]
pub policy_rule: Option<String>,
/// The full Azure Policy definition JSON string (alternative to `policy_rule`).
#[serde(default)]
pub policy_definition: Option<String>,
/// Resource properties supplied as the evaluation input document.
#[serde(default)]
pub resource: Option<serde_yaml::Value>,
/// Policy parameters supplied to the policy evaluation.
#[serde(default)]
pub parameters: Option<serde_yaml::Value>,
/// Expected effect produced by policy evaluation when the rule matches.
#[serde(default)]
pub want_effect: Option<String>,
/// Expected details object in the structured effect result.
/// When set, the test verifies `result.details == want_details`.
#[serde(default)]
pub want_details: Option<serde_yaml::Value>,
/// If true, the compilation is expected to fail (e.g. modifiable check).
#[serde(default)]
pub want_compile_error: Option<bool>,
/// If true, evaluation is expected to produce `Value::Undefined`
/// (the condition does not match and the policy has no effect).
#[serde(default)]
pub want_undefined: Option<bool>,
/// If true, the policy_rule is expected to fail parsing.
#[serde(default)]
pub want_parse_error: Option<bool>,
/// Optional API version for the resource (e.g., "2023-01-01").
/// When set, injected as `input.resource.apiversion` (lowercased to match
/// the compiler's lowercased lookup paths) so policies and alias-versioned
/// path selection can reference it.
#[serde(default)]
pub api_version: Option<String>,
/// Optional request context object for the evaluation.
/// When set, injected as `context.requestContext`. Used by policies
/// that reference `[requestContext().apiVersion]` or other request
/// infrastructure fields.
///
/// This is distinct from `api_version`, which specifies the resource's
/// own API version for alias versioned path selection and
/// `resource.apiVersion`. When `request_context` is absent but
/// `api_version` is present, `api_version` is used as a fallback
/// for `context.requestContext.apiVersion` (backward compatibility).
#[serde(default)]
pub request_context: Option<serde_yaml::Value>,
/// Optional custom context object. Overrides the default test context
/// (resourceGroup, subscription). Useful for testing `resourceGroup()`,
/// `subscription()`, and other context-dependent expressions.
#[serde(default)]
pub context: Option<serde_yaml::Value>,
/// Host-await response entries for cross-resource effects
/// (`auditIfNotExists` / `deployIfNotExists`).
///
/// Each entry maps a request key (describing the lookup) to a response
/// value. These are injected into the VM as run-to-completion host
/// await responses keyed by `"azure.policy.existence_check"`.
///
/// The response should be the related resource object (for found
/// resources) or `null` (when the resource does not exist).
#[serde(default)]
pub host_await: Vec<HostAwaitEntry>,
/// If true, skip this test case.
#[serde(default)]
pub skip: Option<bool>,
}
/// A single host-await response entry.
///
/// ```yaml
/// host_await:
/// - key:
/// operation: "lookup_related_resources"
/// type: "Microsoft.Insights/diagnosticSettings"
/// response:
/// properties:
/// logs:
/// - enabled: true
/// ```
///
/// Use `response: null` when the related resource does not exist.
#[derive(Serialize, Deserialize, Debug)]
#[serde(deny_unknown_fields)]
struct HostAwaitEntry {
/// Descriptive key identifying the request (not used at runtime;
/// serves as documentation in YAML tests).
#[serde(default)]
pub key: Option<serde_yaml::Value>,
/// The fully-qualified ARM resource type of the related resource
/// (e.g., `"Microsoft.Insights/diagnosticSettings"`). When an alias
/// catalog is loaded, the test harness uses this to normalize the
/// response through the same alias-driven normalization that the
/// primary resource receives.
#[serde(default)]
pub resource_type: Option<String>,
/// The value the VM receives as the host-await response.
pub response: serde_yaml::Value,
}
/// Top-level YAML test file structure.
#[derive(Serialize, Deserialize, Debug)]
#[serde(deny_unknown_fields)]
struct YamlTest {
/// Optional path to an aliases JSON file (relative to
/// `tests/azure_policy/aliases/`). When present, the alias catalog is
/// loaded into an `AliasRegistry` and each test case's `resource` is
/// treated as raw ARM JSON and run through the normalizer (root
/// `properties` flattening + sub-resource array flattening) before
/// evaluation.
#[serde(default)]
pub aliases: Option<String>,
/// Optional global policy rule JSON string. Used as the default for test
/// cases that don't specify their own `policy_rule` or `policy_definition`.
/// Avoids duplicating the same policy across many test cases.
#[serde(default)]
pub policy_rule: Option<String>,
/// Optional global policy definition JSON string (alternative to `policy_rule`).
#[serde(default)]
pub policy_definition: Option<String>,
pub cases: Vec<TestCase>,
}
/// Filter test cases by the `TEST_CASE_FILTER` environment variable.
fn should_run_test_case(case_note: &str) -> bool {
if let Ok(filter) = std::env::var("TEST_CASE_FILTER") {
case_note.contains(&filter)
} else {
true
}
}
/// Run all test cases from a YAML file.
fn yaml_test_impl(file: &str) -> Result<()> {
let yaml_str = fs::read_to_string(file)?;
let test: YamlTest = serde_yaml::from_str(&yaml_str)?;
// Load alias registry if an aliases file is specified.
let alias_registry = if let Some(ref aliases_file) = test.aliases {
let aliases_dir = Path::new(file)
.parent()
.unwrap_or_else(|| Path::new("."))
.join("../aliases")
.join(aliases_file);
let aliases_json = fs::read_to_string(&aliases_dir).map_err(|e| {
anyhow::anyhow!(
"Failed to load aliases file {}: {}",
aliases_dir.display(),
e
)
})?;
let mut registry = AliasRegistry::new();
registry.load_from_json(&aliases_json)?;
Some(registry)
} else {
None
};
println!("running {file}");
if let Some(ref reg) = alias_registry {
println!(" Aliases loaded ({} resource types)", reg.len());
}
if let Ok(filter) = std::env::var("TEST_CASE_FILTER") {
println!(" Test case filter active: '{filter}'");
}
let mut executed_count = 0usize;
let mut skipped_count = 0usize;
for case in &test.cases {
if !should_run_test_case(&case.note) {
println!(" case {} filtered out", case.note);
skipped_count += 1;
continue;
}
print!(" case {} ", case.note);
if case.skip == Some(true) {
println!("skipped");
skipped_count += 1;
continue;
}
executed_count += 1;
let expects_parse_error = case.want_parse_error == Some(true);
// Determine source and parse mode.
// Case-level policy_definition/policy_rule takes precedence over
// top-level (global) policy_definition/policy_rule.
let (source_text, use_definition) = if let Some(ref defn) = case.policy_definition {
(defn.clone(), true)
} else if let Some(ref rule) = case.policy_rule {
(rule.clone(), false)
} else if let Some(ref defn) = test.policy_definition {
(defn.clone(), true)
} else if let Some(ref rule) = test.policy_rule {
(rule.clone(), false)
} else {
bail!(
"case '{}': must specify either 'policy_rule' or 'policy_definition'",
case.note
);
};
// Keep a reference for extracting parameter defaults later.
let source = Source::from_contents(format!("test:{}", case.note), source_text)?;
// Parse and compile.
//
// When the source is a full policy definition we parse to
// `PolicyDefinition` and compile via `compile_policy_definition*`
// which bakes parameter `defaultValue`s into the program's literal
// table. When it's just a policy rule we parse/compile directly.
let compile_result: Result<_> = if use_definition {
match parser::parse_policy_definition(&source) {
Ok(defn) => {
if expects_parse_error {
bail!(
"case '{}': expected parse error but parsing succeeded",
case.note
);
}
if let Some(ref registry) = alias_registry {
compiler::compile_policy_definition_with_aliases(
&defn,
registry.alias_map(),
registry.alias_modifiable_map(),
)
} else {
compiler::compile_policy_definition(&defn)
}
}
Err(e) => {
if expects_parse_error {
println!("passed (expected parse error: {})", e);
continue;
}
bail!("case '{}': unexpected parse error: {}", case.note, e);
}
}
} else {
match parser::parse_policy_rule(&source) {
Ok(ast) => {
if expects_parse_error {
bail!(
"case '{}': expected parse error but parsing succeeded",
case.note
);
}
if let Some(ref registry) = alias_registry {
compiler::compile_policy_rule_with_aliases(
&ast,
registry.alias_map(),
registry.alias_modifiable_map(),
)
} else {
compiler::compile_policy_rule(&ast)
}
}
Err(e) => {
if expects_parse_error {
println!("passed (expected parse error: {})", e);
continue;
}
bail!("case '{}': unexpected parse error: {}", case.note, e);
}
}
};
let expects_compile_error = case.want_compile_error == Some(true);
let program = match compile_result {
Ok(prog) => {
if expects_compile_error {
bail!(
"case '{}': expected compile error but compilation succeeded",
case.note
);
}
prog
}
Err(e) => {
if expects_compile_error {
println!("passed (expected compile error: {})", e);
continue;
}
bail!("case '{}': unexpected compile error: {}", case.note, e);
}
};
// Extract the details.type from the policy for host_await
// normalization (so test authors don't have to repeat it in YAML).
let details_type = extract_details_resource_type(source.contents(), use_definition);
// Debug: dump compiled program listing
if std::env::var("DEBUG_LISTING").is_ok() {
let listing = regorus::rvm::generate_assembly_listing(
&program,
&regorus::rvm::AssemblyListingConfig::default(),
);
eprintln!(
"=== COMPILED LISTING ===\n{}\n========================",
listing
);
}
let mut vm = RegoVM::new();
vm.load_program(program);
vm.set_input(make_input(case, alias_registry.as_ref())?);
vm.set_context(make_context(case)?);
// Load host-await responses (for auditIfNotExists / deployIfNotExists policies).
// When an alias catalog is loaded, the response is normalized through
// the same alias-driven normalizer that the primary resource receives.
// The resource type is injected into the response from the policy's
// `details.type` (or overridden per-entry) so the normalizer can find
// the right alias entries.
if !case.host_await.is_empty() {
let mut responses: BTreeMap<Value, Vec<Value>> = BTreeMap::new();
for entry in &case.host_await {
let response_value = if let Some(ref registry) = alias_registry {
// Determine the resource type: per-entry override > policy details.type.
let effective_type = entry.resource_type.as_deref().or(details_type.as_deref());
// Inject the type into object responses so the normalizer
// can look up alias entries (real ARM responses always
// include a "type" field). Non-object responses (e.g. null)
// pass through unchanged.
let mut raw = yaml_to_regorus_value(Some(&entry.response))?
.unwrap_or_else(Value::new_object);
if matches!(&raw, Value::Object(_)) {
if let Some(rt) = effective_type {
inject_type_field(&mut raw, rt);
}
normalizer::normalize(&raw, Some(registry), case.api_version.as_deref())
} else {
raw
}
} else {
// No alias registry — lowercase all keys in the
// response to match the compiler's lowercased lookups.
let raw = yaml_to_regorus_value(Some(&entry.response))?
.unwrap_or_else(Value::new_object);
lowercase_value_keys(&raw)
};
responses
.entry(Value::from("azure.policy.existence_check"))
.or_default()
.push(response_value);
}
vm.set_host_await_responses(responses);
}
let value = vm.execute_entry_point_by_name("main")?;
if case.want_undefined == Some(true) {
assert_eq!(
value,
Value::Undefined,
"case '{}': expected undefined, got {}",
case.note,
value
);
println!("passed (compiled + undefined)");
continue;
}
if let Some(effect) = &case.want_effect {
// The compiled result is now a structured object `{ "effect": "...", ... }`.
// Extract the "effect" field for comparison.
let effect_value = extract_effect_name(&value);
let expected = Value::from(effect.clone());
assert_eq!(
effect_value, expected,
"case '{}': expected effect {:?}, got {} (full result: {})",
case.note, effect, effect_value, value
);
// Check details if expected.
if let Some(ref want_details) = case.want_details {
let actual_details = extract_details(&value);
let expected_details =
yaml_to_regorus_value(Some(want_details))?.unwrap_or(Value::Undefined);
assert_eq!(
actual_details, expected_details,
"case '{}': details mismatch.\n actual: {}\n expected: {}",
case.note, actual_details, expected_details
);
}
println!("passed (compiled + effect={})", effect);
} else {
println!("passed (compiled)");
}
}
println!(
" Summary for {}: {} executed, {} skipped",
file, executed_count, skipped_count
);
Ok(())
}
fn make_input(case: &TestCase, alias_registry: Option<&AliasRegistry>) -> Result<Value> {
let parameters =
yaml_to_regorus_value(case.parameters.as_ref())?.unwrap_or_else(Value::new_object);
let mut resource = if alias_registry.is_some() {
// When an alias registry is available, run the full normalizer:
// root-field extraction, `properties` flattening, key lowercasing,
// alias-specific path resolution. This mirrors production behaviour
// where the host normalizes inputs before evaluation.
let raw = yaml_to_regorus_value(case.resource.as_ref())?.unwrap_or_else(Value::new_object);
normalizer::normalize(&raw, alias_registry, case.api_version.as_deref())
} else {
// No alias registry — tests provide resources in ARM-like shape
// (e.g. `properties.count` nested under `resource.properties`).
// We lowercase all object keys so that built-in field lookups
// (`fullName` → `fullname`, `apiVersion` → `apiversion`, tag names)
// match the compiler's lowercased lookup paths.
let raw = yaml_to_regorus_value(case.resource.as_ref())?.unwrap_or_else(Value::new_object);
lowercase_value_keys(&raw)
};
// Inject api_version into the resource if specified.
// Use lowercase key to match normalizer-lowercased keys and
// the compiler's lowercased lookup paths.
if let Some(ref api_ver) = case.api_version {
let map = resource.as_object_mut()?;
map.insert(Value::from("apiversion"), Value::from(api_ver.clone()));
}
// Inject `fullname` when the resource has a `name` but no explicit
// `fullname`. In Azure Policy, `field('fullName')` is a platform-
// provided built-in that returns the complete ancestor-qualified name.
// For test resources the YAML `name` already contains the full name
// (ARM names include ancestor segments for child resources).
{
let map = resource.as_object_mut()?;
if map.get(&Value::from("fullname")).is_none() {
if let Some(name_val) = map.get(&Value::from("name")).cloned() {
map.insert(Value::from("fullname"), name_val);
}
}
}
// Debug: print normalized resource for troubleshooting
if std::env::var("DEBUG_RESOURCE").is_ok() {
eprintln!("DEBUG normalized resource: {}", resource);
}
let mut input = Value::new_object();
let map = input.as_object_mut()?;
map.insert(Value::from("resource"), resource);
map.insert(Value::from("parameters"), parameters);
Ok(input)
}
fn make_context(case: &TestCase) -> Result<Value> {
let mut ctx = if let Some(ref ctx) = case.context {
yaml_to_regorus_value(Some(ctx))?.unwrap_or_else(Value::new_object)
} else {
Value::from_json_str(
r#"{
"resourceGroup": {
"name": "myResourceGroup",
"location": "eastus"
},
"subscription": {
"subscriptionId": "00000000-0000-0000-0000-000000000000"
}
}"#,
)?
};
// Inject requestContext so that `[requestContext().apiVersion]` and other
// request infrastructure expressions resolve correctly.
//
// Priority:
// 1. Explicit `request_context` YAML field → injected as-is.
// 2. Fallback: `api_version` → synthesizes `{ apiVersion: "<ver>" }`.
//
// In production the host provides the full requestContext; the test
// harness mirrors the same contract.
if let Some(ref rc) = case.request_context {
let rc_val = yaml_to_regorus_value(Some(rc))?.unwrap_or_else(Value::new_object);
let map = ctx.as_object_mut()?;
// Only inject if the caller didn't already provide requestContext
// in the context object, to avoid clobbering custom test setups.
map.entry(Value::from("requestContext")).or_insert(rc_val);
} else if let Some(ref api_ver) = case.api_version {
let map = ctx.as_object_mut()?;
if let std::collections::btree_map::Entry::Vacant(e) =
map.entry(Value::from("requestContext"))
{
let mut req_ctx = Value::new_object();
let rc_map = req_ctx.as_object_mut()?;
rc_map.insert(Value::from("apiVersion"), Value::from(api_ver.clone()));
e.insert(req_ctx);
}
}
Ok(ctx)
}
fn yaml_to_regorus_value(value: Option<&serde_yaml::Value>) -> Result<Option<Value>> {
let Some(value) = value else {
return Ok(None);
};
let json = serde_json::to_string(value)?;
let regorus_value = Value::from_json_str(&json)?;
Ok(Some(regorus_value))
}
/// Recursively lowercase all object keys in a `Value`.
///
/// Used for tests without an alias registry so that built-in field lookups
/// (e.g. `fullName` → `fullname`, tag names) match the compiler's lowercased
/// lookup paths without running the full normalizer (which also flattens
/// `properties`).
fn lowercase_value_keys(value: &Value) -> Value {
match value {
Value::Object(btree) => {
let mut result = Value::new_object();
let map = result.as_object_mut().unwrap();
for (k, v) in btree.iter() {
let lc_key = match k {
Value::String(s) => Value::String(s.to_lowercase().into()),
other => other.clone(),
};
map.insert(lc_key, lowercase_value_keys(v));
}
result
}
Value::Array(arr) => {
let items: Vec<Value> = arr.iter().map(lowercase_value_keys).collect();
Value::from(items)
}
_ => value.clone(),
}
}
/// Extract the effect name from a VM evaluation result.
///
/// If the value is an object containing `{ "effect": ... }`, returns that
/// field's value. If the value is a plain string (legacy format), returns it
/// directly.
fn extract_effect_name(value: &Value) -> Value {
if let Ok(obj) = value.as_object() {
if let Some(effect) = obj.get(&Value::from("effect")) {
return effect.clone();
}
}
// Legacy: plain string result
value.clone()
}
/// Extract the details object from a structured result.
fn extract_details(value: &Value) -> Value {
if let Ok(obj) = value.as_object() {
if let Some(details) = obj.get(&Value::from("details")) {
return details.clone();
}
}
Value::Undefined
}
/// Extract the `details.type` resource type from a policy JSON string.
///
/// For AINE/DINE policies, `details.type` specifies the ARM resource type of
/// the related resource that the host must look up. The test harness uses
/// this to inject the type into host_await responses so the normalizer can
/// find the right alias entries — no need for test authors to repeat the type
/// in each `host_await` entry.
///
/// Handles both full policy definitions (`properties.policyRule.then.details.type`)
/// and standalone policy rules (`then.details.type`).
fn extract_details_resource_type(source_text: &str, is_definition: bool) -> Option<String> {
let json: serde_json::Value = serde_json::from_str(source_text).ok()?;
let rule = if is_definition {
// Try wrapped form first (`properties.policyRule`), fall back to
// unwrapped (`policyRule` at top level).
json.get("properties")
.and_then(|p| p.get("policyRule"))
.or_else(|| json.get("policyRule"))?
} else {
&json
};
rule.get("then")?
.get("details")?
.get("type")?
.as_str()
.map(String::from)
}
/// Inject a `type` field into a regorus Value object.
///
/// Used to add the resource type to host_await responses before normalization,
/// since the normalizer derives the resource type from the resource's `type`
/// field. Real ARM responses always include `type`; the test YAML omits it
/// for brevity.
fn inject_type_field(value: &mut Value, resource_type: &str) {
if let Ok(obj) = value.as_object_mut() {
let key = Value::from("type");
if obj.get(&key).is_none() {
obj.insert(key, Value::from(resource_type));
}
}
}
#[test_resources("tests/azure_policy/cases/*.yaml")]
fn run_azure_policy_yaml(file: &str) {
yaml_test_impl(file).unwrap();
}
#[test]
fn test_specific_case() {
if std::env::var("TEST_CASE_FILTER").is_err() {
println!("Specific case test skipped - no TEST_CASE_FILTER set");
println!(" Usage: TEST_CASE_FILTER=\"note substring\" cargo test --features azure_policy test_specific_case -- --nocapture");
return;
}
if let Ok(entries) = fs::read_dir("tests/azure_policy/cases") {
let mut failures = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) == Some("yaml") {
if let Err(e) = yaml_test_impl(path.to_str().unwrap()) {
failures.push(format!("Error in file {}: {}", path.display(), e));
}
}
}
if !failures.is_empty() {
panic!(
"test_specific_case found {} failing file(s):\n{}",
failures.len(),
failures.join("\n")
);
}
}
}

View File

@@ -8,7 +8,7 @@
//! `"if"` / `"then"` structure. The test runner extracts the `"if"` constraint //! `"if"` / `"then"` structure. The test runner extracts the `"if"` constraint
//! JSON and parses it with `parse_constraint`. //! JSON and parses it with `parse_constraint`.
use anyhow::Result; use anyhow::{bail, Result};
use regorus::languages::azure_policy::parser; use regorus::languages::azure_policy::parser;
use regorus::Source; use regorus::Source;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -64,8 +64,8 @@ fn should_run_test_case(case_note: &str) -> bool {
/// ///
/// Returns `None` if parsing fails or there is no `"if"` key (the caller /// Returns `None` if parsing fails or there is no `"if"` key (the caller
/// should feed the raw string to `parse_constraint` for error tests). /// should feed the raw string to `parse_constraint` for error tests).
fn extract_if_json(policy_rule_json: &str) -> Option<String> { fn extract_if_json(source_json: &str) -> Option<String> {
let v: serde_json::Value = serde_json::from_str(policy_rule_json).ok()?; let v: serde_json::Value = serde_json::from_str(source_json).ok()?;
let if_value = v.get("if")?; let if_value = v.get("if")?;
Some(if_value.to_string()) Some(if_value.to_string())
} }
@@ -102,12 +102,12 @@ fn yaml_test_impl(file: &str) -> Result<()> {
let expects_parse_error = case.want_parse_error == Some(true); let expects_parse_error = case.want_parse_error == Some(true);
let input_json = if let Some(ref rule) = case.policy_rule { let source_json = if let Some(ref rule) = case.policy_rule {
rule.clone() rule.clone()
} else if let Some(ref rule) = test.policy_rule { } else if let Some(ref rule) = test.policy_rule {
rule.clone() rule.clone()
} else { } else {
panic!("case '{}': must specify 'policy_rule'", case.note); bail!("case '{}': must specify 'policy_rule'", case.note);
}; };
let parse_level = case.parse_level.as_deref().unwrap_or("constraint"); let parse_level = case.parse_level.as_deref().unwrap_or("constraint");
@@ -115,35 +115,33 @@ fn yaml_test_impl(file: &str) -> Result<()> {
let parse_result = match parse_level { let parse_result = match parse_level {
"policy_rule" => { "policy_rule" => {
// Parse the full policy_rule JSON with parse_policy_rule. // Parse the full policy_rule JSON with parse_policy_rule.
let source = Source::from_contents(format!("test:{}", case.note), input_json)?; let source = Source::from_contents(format!("test:{}", case.note), source_json)?;
parser::parse_policy_rule(&source).map(|_| ()) parser::parse_policy_rule(&source).map(|_| ())
} }
"policy_definition" => { "policy_definition" => {
// Parse the full policy definition JSON with parse_policy_definition. // Parse the full policy definition JSON with parse_policy_definition.
let source = Source::from_contents(format!("test:{}", case.note), input_json)?; let source = Source::from_contents(format!("test:{}", case.note), source_json)?;
parser::parse_policy_definition(&source).map(|_| ()) parser::parse_policy_definition(&source).map(|_| ())
} }
"constraint" => { "constraint" => {
// Extract the "if" constraint JSON. If extraction fails // Extract the "if" constraint JSON. If extraction fails
// (malformed JSON or missing "if" key), feed the raw // (malformed JSON or missing "if" key), feed the raw source
// input to parse_constraint — it should fail, // JSON to parse_constraint — it should fail, matching
// matching want_parse_error. // want_parse_error.
let constraint_json = match extract_if_json(&input_json) { let constraint_json =
Some(json) => json, extract_if_json(&source_json).unwrap_or_else(|| source_json.clone());
None => input_json,
};
let source = Source::from_contents(format!("test:{}", case.note), constraint_json)?; let source = Source::from_contents(format!("test:{}", case.note), constraint_json)?;
parser::parse_constraint(&source).map(|_| ()) parser::parse_constraint(&source).map(|_| ())
} }
other => { other => {
panic!("case '{}': unknown parse_level '{}'", case.note, other); bail!("case '{}': unknown parse_level '{}'", case.note, other);
} }
}; };
match parse_result { match parse_result {
Ok(()) => { Ok(()) => {
if expects_parse_error { if expects_parse_error {
panic!( bail!(
"case '{}': expected parse error but parsing succeeded", "case '{}': expected parse error but parsing succeeded",
case.note case.note
); );
@@ -154,7 +152,7 @@ fn yaml_test_impl(file: &str) -> Result<()> {
if expects_parse_error { if expects_parse_error {
println!("passed (expected parse error: {})", e); println!("passed (expected parse error: {})", e);
} else { } else {
panic!("case '{}': unexpected parse error: {}", case.note, e); bail!("case '{}': unexpected parse error: {}", case.note, e);
} }
} }
} }

View File

@@ -41,25 +41,29 @@ cases:
args: ["2024-01-15T12:00:00Z", "invalid"] args: ["2024-01-15T12:00:00Z", "invalid"]
want_undefined: true want_undefined: true
# Input without explicit timezone is treated as UTC; outputs are
# normalized to ISO 8601 with an explicit timezone (`Z` for UTC).
- note: datetime_without_timezone - note: datetime_without_timezone
args: ["2024-01-15T12:00:00", "P1D"] args: ["2024-01-15T12:00:00", "P1D"]
want: "2024-01-16T12:00:00" want: "2024-01-16T12:00:00Z"
- note: add_one_week - note: add_one_week
args: ["2024-01-15T00:00:00Z", "P1W"] args: ["2024-01-15T00:00:00Z", "P1W"]
want: "2024-01-22T00:00:00Z" want: "2024-01-22T00:00:00Z"
# Azure Policy normalizes all output to ISO 8601 T-separated format with
# explicit timezone, regardless of input format.
- note: space_separated_datetime - note: space_separated_datetime
args: ["2020-04-07 14:55:59", "P3D"] args: ["2020-04-07 14:55:59", "P3D"]
want: "2020-04-10 14:55:59" want: "2020-04-10T14:55:59Z"
- note: space_separated_datetime_with_z - note: space_separated_datetime_with_z
args: ["2020-04-07 14:55:59Z", "P3D"] args: ["2020-04-07 14:55:59Z", "P3D"]
want: "2020-04-10 14:55:59Z" want: "2020-04-10T14:55:59Z"
- note: space_separated_datetime_with_offset - note: space_separated_datetime_with_offset
args: ["2020-04-07 14:55:59+05:30", "P1D"] args: ["2020-04-07 14:55:59+05:30", "P1D"]
want: "2020-04-08 14:55:59+05:30" want: "2020-04-08T14:55:59+05:30"
- note: output_format_date_only - note: output_format_date_only
args: ["2020-04-07T14:55:59Z", "P3Y2M", "yyyy-MM-dd"] args: ["2020-04-07T14:55:59Z", "P3Y2M", "yyyy-MM-dd"]
@@ -98,30 +102,34 @@ cases:
args: ["2024-01-15T12:00:00+05:30", "P0D", "u"] args: ["2024-01-15T12:00:00+05:30", "P0D", "u"]
want: "2024-01-15 06:30:00Z" want: "2024-01-15 06:30:00Z"
# Fractional seconds preservation (default round-trip, no explicit format) # When no explicit output format is provided, datetime output is normalized
# to ISO 8601 T-separated form with timezone, regardless of input format.
# Explicit third-argument formats may intentionally produce other forms
# (for example, space-separated output). Fractional seconds are preserved
# when non-zero.
- note: roundtrip_iso_no_tz_with_frac - note: roundtrip_iso_no_tz_with_frac
args: ["2024-01-15T12:00:00.123", "P0D"] args: ["2024-01-15T12:00:00.123", "P0D"]
want: "2024-01-15T12:00:00.123" want: "2024-01-15T12:00:00.123Z"
- note: roundtrip_iso_no_tz_with_frac_add - note: roundtrip_iso_no_tz_with_frac_add
args: ["2024-01-15T12:00:00.500", "P1D"] args: ["2024-01-15T12:00:00.500", "P1D"]
want: "2024-01-16T12:00:00.500" want: "2024-01-16T12:00:00.500Z"
- note: roundtrip_space_no_tz_with_frac - note: roundtrip_space_no_tz_with_frac
args: ["2024-01-15 12:00:00.456", "P0D"] args: ["2024-01-15 12:00:00.456", "P0D"]
want: "2024-01-15 12:00:00.456" want: "2024-01-15T12:00:00.456Z"
- note: roundtrip_space_z_with_frac - note: roundtrip_space_z_with_frac
args: ["2024-01-15 12:00:00.789Z", "P0D"] args: ["2024-01-15 12:00:00.789Z", "P0D"]
want: "2024-01-15 12:00:00.789Z" want: "2024-01-15T12:00:00.789Z"
- note: roundtrip_space_offset_with_frac - note: roundtrip_space_offset_with_frac
args: ["2024-01-15 12:00:00.123+05:30", "P0D"] args: ["2024-01-15 12:00:00.123+05:30", "P0D"]
want: "2024-01-15 12:00:00.123+05:30" want: "2024-01-15T12:00:00.123+05:30"
- note: roundtrip_no_frac_stays_clean - note: roundtrip_no_frac_stays_clean
args: ["2024-01-15T12:00:00", "P0D"] args: ["2024-01-15T12:00:00", "P0D"]
want: "2024-01-15T12:00:00" want: "2024-01-15T12:00:00Z"
- note: roundtrip_rfc3339_utc_with_frac - note: roundtrip_rfc3339_utc_with_frac
args: ["2024-01-15T12:00:00.123Z", "P0D"] args: ["2024-01-15T12:00:00.123Z", "P0D"]
@@ -137,11 +145,11 @@ cases:
- note: roundtrip_rfc3339_explicit_zero_offset - note: roundtrip_rfc3339_explicit_zero_offset
args: ["2024-01-15T12:00:00+00:00", "P0D"] args: ["2024-01-15T12:00:00+00:00", "P0D"]
want: "2024-01-15T12:00:00+00:00" want: "2024-01-15T12:00:00Z"
- note: roundtrip_rfc3339_explicit_zero_offset_with_frac - note: roundtrip_rfc3339_explicit_zero_offset_with_frac
args: ["2024-01-15T12:00:00.500+00:00", "P0D"] args: ["2024-01-15T12:00:00.500+00:00", "P0D"]
want: "2024-01-15T12:00:00.500+00:00" want: "2024-01-15T12:00:00.500Z"
# Unknown single-char format specifier is a real error # Unknown single-char format specifier is a real error
- note: unknown_format_specifier_q - note: unknown_format_specifier_q

View File

@@ -339,7 +339,7 @@ fn custom_max_col_allows_wide_line() -> Result<()> {
use regorus::{Engine, PolicyLengthConfig}; use regorus::{Engine, PolicyLengthConfig};
// A line wider than the default 1024 columns. // A line wider than the default 1024 columns.
let wide = format!("package test\na := \"{}\"", "x".repeat(2000)); let wide = format!("package test\na := \"{}\"", "x".repeat(9000));
let mut engine = Engine::new(); let mut engine = Engine::new();
@@ -351,7 +351,7 @@ fn custom_max_col_allows_wide_line() -> Result<()> {
// Should succeed with a raised max_col. // Should succeed with a raised max_col.
engine.set_policy_length_config(PolicyLengthConfig { engine.set_policy_length_config(PolicyLengthConfig {
max_col: NonZeroU32::new(4096).unwrap(), max_col: NonZeroU32::new(16384).unwrap(),
..Default::default() ..Default::default()
}); });
engine.add_policy("wide.rego".into(), wide)?; engine.add_policy("wide.rego".into(), wide)?;