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 = []
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"]
base64 = ["dep:data-encoding"]
base64url = ["dep:data-encoding"]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -537,3 +537,73 @@ cases:
resource:
type: "Microsoft.Storage/storageAccounts"
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.
# Licensed under the MIT License.
# Parse Error Test Suite
# Tests that malformed policy JSON and invalid constructs are properly rejected.
# These test cases are expected to fail parsing.
# Parse Error and Edge Case Test Suite
# Tests mostly malformed policy JSON and invalid constructs to ensure they are
# properly rejected, but also includes valid edge cases that verify parser
# behavior at boundary conditions.
cases:
# =========================================================================
@@ -267,3 +268,33 @@ cases:
resource:
type: "any"
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.
// 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 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
//! JSON and parses it with `parse_constraint`.
use anyhow::Result;
use anyhow::{bail, Result};
use regorus::languages::azure_policy::parser;
use regorus::Source;
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
/// should feed the raw string to `parse_constraint` for error tests).
fn extract_if_json(policy_rule_json: &str) -> Option<String> {
let v: serde_json::Value = serde_json::from_str(policy_rule_json).ok()?;
fn extract_if_json(source_json: &str) -> Option<String> {
let v: serde_json::Value = serde_json::from_str(source_json).ok()?;
let if_value = v.get("if")?;
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 input_json = if let Some(ref rule) = case.policy_rule {
let source_json = if let Some(ref rule) = case.policy_rule {
rule.clone()
} else if let Some(ref rule) = test.policy_rule {
rule.clone()
} 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");
@@ -115,35 +115,33 @@ fn yaml_test_impl(file: &str) -> Result<()> {
let parse_result = match parse_level {
"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(|_| ())
}
"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(|_| ())
}
"constraint" => {
// Extract the "if" constraint JSON. If extraction fails
// (malformed JSON or missing "if" key), feed the raw
// input to parse_constraint — it should fail,
// matching want_parse_error.
let constraint_json = match extract_if_json(&input_json) {
Some(json) => json,
None => input_json,
};
// (malformed JSON or missing "if" key), feed the raw source
// JSON to parse_constraint — it should fail, matching
// want_parse_error.
let constraint_json =
extract_if_json(&source_json).unwrap_or_else(|| source_json.clone());
let source = Source::from_contents(format!("test:{}", case.note), constraint_json)?;
parser::parse_constraint(&source).map(|_| ())
}
other => {
panic!("case '{}': unknown parse_level '{}'", case.note, other);
bail!("case '{}': unknown parse_level '{}'", case.note, other);
}
};
match parse_result {
Ok(()) => {
if expects_parse_error {
panic!(
bail!(
"case '{}': expected parse error but parsing succeeded",
case.note
);
@@ -154,7 +152,7 @@ fn yaml_test_impl(file: &str) -> Result<()> {
if expects_parse_error {
println!("passed (expected parse error: {})", e);
} 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"]
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
args: ["2024-01-15T12:00:00", "P1D"]
want: "2024-01-16T12:00:00"
want: "2024-01-16T12:00:00Z"
- note: add_one_week
args: ["2024-01-15T00:00:00Z", "P1W"]
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
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
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
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
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"]
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
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
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
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
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
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
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
args: ["2024-01-15T12:00:00.123Z", "P0D"]
@@ -137,11 +145,11 @@ cases:
- note: roundtrip_rfc3339_explicit_zero_offset
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
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
- note: unknown_format_specifier_q

View File

@@ -339,7 +339,7 @@ fn custom_max_col_allows_wide_line() -> Result<()> {
use regorus::{Engine, PolicyLengthConfig};
// 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();
@@ -351,7 +351,7 @@ fn custom_max_col_allows_wide_line() -> Result<()> {
// Should succeed with a raised max_col.
engine.set_policy_length_config(PolicyLengthConfig {
max_col: NonZeroU32::new(4096).unwrap(),
max_col: NonZeroU32::new(16384).unwrap(),
..Default::default()
});
engine.add_policy("wide.rego".into(), wide)?;