feat: add Azure Policy builtins with YAML test suite (#630)

* feat: add Azure Policy builtins with YAML test suite

Implement ARM template functions for Azure Policy evaluation:

Builtins:
- String: indexOf, lastIndexOf, trim, format, split, startsWith, endsWith,
  padLeft, concat, replace, toLower, toUpper, substring, guid, uniqueString
- DateTime: dateTimeAdd, dateTimeFromEpoch, dateTimeToEpoch, addDays
- Collection: intersection, union, take, skip, first, last, min, max,
  range, items, tryGet, tryIndexFromEnd, empty, array, createObject
- Encoding: base64, base64ToString, base64ToJson, uri, uriComponent,
  uriComponentToString, dataUri, dataUriToString
- Numeric: int, float, intDiv, intMod
- Misc: json, join, bool, string, coalesce, if, getParameter, resolveField
- Logic: logicAll, logicAny

Key implementation details:
- Unicode case-insensitive search via ICU4X case folding with single-pass
  fold_with_char_map() for indexOf/lastIndexOf
- .NET composite formatting (System.String.Format) with alignment, standard
  and custom datetime format specifiers, numeric format specifiers
- DateTime round-trip preserves input shape (Z vs +00:00, T vs space,
  fractional seconds) when no explicit output format is supplied
- Zero-cost as_str() helper borrows directly from Value::String(Rc<str>)
- BTreeSet<&Value> in array union avoids redundant cloning

Test suite:
- 53 YAML test files exercising all builtins via direct BUILTINS registry
- Coverage for edge cases: empty inputs, Unicode, fractional seconds,
  invalid alignment, unknown format specifiers, RFC3339 offset shapes

Signed-off-by: Anand Krishnamoorthi <anakrish@microsoft.com>

* fix: address PR review comments

- Fix percent_encode to only uppercase hex digits, not entire string
- Remove guid/uniqueString (unsupported); delete custom SHA-1 impl
- Replace unwrap_or(0) with proper error in format placeholder parsing
- Hoist CaseMapper into static CaseMapperBorrowed for zero per-call overhead
- Pre-allocate Vec in range() with_capacity
- Update bindings/ffi and bindings/ruby Cargo.lock
- Fix uri_component test expectations for correct case preservation

Signed-off-by: Anand Krishnamoorthi <anakrish@microsoft.com>

* fix: address second round of PR review comments

- float(): return Undefined when as_f64() fails instead of leaking
  the original non-f64 representation
- createObject(): reject odd number of arguments with an error
  (ARM-template parity)
- format(): error on unknown numeric format specifiers instead of
  silently passing through (matches .NET FormatException behavior)
- format(): cap alignment width at 10,000 to prevent DoS from
  user-controlled format strings like {0,1000000000}
- Add YAML test cases for all new error behaviors

Signed-off-by: Anand Krishnamoorthi <anakrish@microsoft.com>

* fix: address third round of PR review comments

- percent_decode: reject incomplete % escapes (e.g. "%", "%2") instead
  of treating them as literal characters
- parse_iso8601_duration: reject leftover digits without a unit designator
  at T boundary and end-of-input (e.g. "P1", "P1T2H")
- yaml_to_value: panic on unsupported YAML numeric representations instead
  of silently mapping to Null
- Revert unused src/languages/mod.rs changes (module is defined inline in
  lib.rs)

Signed-off-by: Anand Krishnamoorthi <anakrish@microsoft.com>

* fix: add missing edge-case tests and fix empty-delimiter panic

- fn_split: return input as single-element array for empty string
  delimiter instead of panicking (Rust's str::split("") panics)
- format: add test for F3 higher precision ({0:F3} + 1.23456 → 1.235)
- format: add test for N2 float with thousands separator
- format: add test for negative index error ({-1})
- split: add test for empty-string delimiter
- uri: add tests for query string and fragment in relative URI
- createObject: add test for non-string (numeric) keys

Signed-off-by: Anand Krishnamoorthi <anakrish@microsoft.com>

* fix: address fourth round of PR review comments

- Add MAX_VARIADIC_ARGS (64) constant for variadic builtin arity
  instead of registering with 0 (logic_all, logic_any, min, max,
  format, intersection, union, coalesce, createObject); set
  dateTimeAdd to exact arity 3

- Switch indexOf/lastIndexOf to UTF-16 code-unit indices to match
  .NET String.IndexOf semantics (track ch.len_utf16() in
  fold_with_char_map, use encode_utf16().count() for empty-needle
  lastIndexOf)

- Use DateTime::<Utc>::from_timestamp for explicit timezone type

- Remove stale docs/azure-policy/casing.md link from module doc

- Fix misleading comment in want_error test branch (code bails on
  Undefined, not accepts it)

Signed-off-by: Anand Krishnamoorthi <anakrish@microsoft.com>

---------

Signed-off-by: Anand Krishnamoorthi <anakrish@microsoft.com>
This commit is contained in:
Anand Krishnamoorthi
2026-03-25 17:32:39 -05:00
committed by GitHub
parent f69974dc1b
commit 5b60daabd9
73 changed files with 5894 additions and 1 deletions

31
Cargo.lock generated
View File

@@ -604,6 +604,28 @@ dependencies = [
"cc",
]
[[package]]
name = "icu_casemap"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4ca9983e8bf51223c2f89014fa4eaa9e9b336c47f3af0d000538f86f841fba1"
dependencies = [
"icu_casemap_data",
"icu_collections",
"icu_locale_core",
"icu_properties",
"icu_provider",
"potential_utf",
"writeable",
"zerovec",
]
[[package]]
name = "icu_casemap_data"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "98d4663d0f99b301033a19e0acf94e9d2fa4b107638580165e5a6ccc49ad1450"
[[package]]
name = "icu_collections"
version = "2.1.1"
@@ -612,6 +634,7 @@ checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43"
dependencies = [
"displaydoc",
"potential_utf",
"serde",
"yoke",
"zerofrom",
"zerovec",
@@ -625,6 +648,7 @@ checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6"
dependencies = [
"displaydoc",
"litemap",
"serde",
"tinystr",
"writeable",
"zerovec",
@@ -678,6 +702,8 @@ checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614"
dependencies = [
"displaydoc",
"icu_locale_core",
"serde",
"stable_deref_trait",
"writeable",
"yoke",
"zerofrom",
@@ -1070,6 +1096,8 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77"
dependencies = [
"serde_core",
"writeable",
"zerovec",
]
@@ -1267,6 +1295,7 @@ dependencies = [
"dashmap",
"data-encoding",
"globset",
"icu_casemap",
"indexmap",
"ipnet",
"jsonschema",
@@ -1511,6 +1540,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869"
dependencies = [
"displaydoc",
"serde_core",
"zerovec",
]
@@ -1912,6 +1942,7 @@ version = "0.11.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002"
dependencies = [
"serde",
"yoke",
"zerofrom",
"zerovec-derive",

View File

@@ -24,7 +24,7 @@ default = ["full-opa", "arc", "rvm"]
arc = []
ast = []
azure_policy = ["dep:jsonschema", "arc", "dashmap"]
azure_policy = ["dep:jsonschema", "dep:chrono", "dep:ipnet", "dep:icu_casemap", "arc", "dashmap"]
azure-rbac = ["regex", "time", "net"]
base64 = ["dep:data-encoding"]
base64url = ["dep:data-encoding"]
@@ -117,6 +117,7 @@ jsonschema = { version = "0.30.0", default-features = false, optional = true }
chrono = { version = "0.4.40", optional = true }
chrono-tz = { version = "0.10.1", optional = true }
ipnet = { version = "2.11.0", optional = true, default-features = false }
icu_casemap = { version = "2.1", optional = true, default-features = false, features = ["compiled_data"] }
serde_yaml = {version = "0.9.16", default-features = false, optional = true }
# Specify thread_rng for in order to use random_range

View File

@@ -450,6 +450,28 @@ dependencies = [
"cc",
]
[[package]]
name = "icu_casemap"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4ca9983e8bf51223c2f89014fa4eaa9e9b336c47f3af0d000538f86f841fba1"
dependencies = [
"icu_casemap_data",
"icu_collections",
"icu_locale_core",
"icu_properties",
"icu_provider",
"potential_utf",
"writeable",
"zerovec",
]
[[package]]
name = "icu_casemap_data"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "98d4663d0f99b301033a19e0acf94e9d2fa4b107638580165e5a6ccc49ad1450"
[[package]]
name = "icu_collections"
version = "2.1.1"
@@ -458,6 +480,7 @@ checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43"
dependencies = [
"displaydoc",
"potential_utf",
"serde",
"yoke",
"zerofrom",
"zerovec",
@@ -471,6 +494,7 @@ checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6"
dependencies = [
"displaydoc",
"litemap",
"serde",
"tinystr",
"writeable",
"zerovec",
@@ -524,6 +548,8 @@ checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614"
dependencies = [
"displaydoc",
"icu_locale_core",
"serde",
"stable_deref_trait",
"writeable",
"yoke",
"zerofrom",
@@ -840,6 +866,8 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77"
dependencies = [
"serde_core",
"writeable",
"zerovec",
]
@@ -987,6 +1015,7 @@ dependencies = [
"dashmap",
"data-encoding",
"globset",
"icu_casemap",
"indexmap",
"ipnet",
"jsonschema",
@@ -1235,6 +1264,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869"
dependencies = [
"displaydoc",
"serde_core",
"zerovec",
]
@@ -1569,6 +1599,7 @@ version = "0.11.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002"
dependencies = [
"serde",
"yoke",
"zerofrom",
"zerovec-derive",

View File

@@ -936,6 +936,7 @@ dependencies = [
"msvc_spectre_libs",
"num-bigint",
"num-traits",
"parking_lot",
"rand",
"regex",
"regorus-mimalloc",

View File

@@ -0,0 +1,151 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
//! Shared helpers: type coercion, comparison, pattern matching, and path resolution.
#![deny(
clippy::arithmetic_side_effects,
clippy::expect_used,
clippy::indexing_slicing,
clippy::panic,
clippy::shadow_unrelated,
clippy::unwrap_used,
clippy::missing_const_for_fn,
clippy::option_if_let_else,
clippy::semicolon_if_nothing_returned,
clippy::useless_let_if_seq
)]
use crate::languages::azure_policy::strings;
use crate::value::Value;
use alloc::string::{String, ToString as _};
use alloc::vec::Vec;
// ── Type helpers ──────────────────────────────────────────────────────
pub const fn is_true(value: &Value) -> bool {
matches!(value, Value::Bool(true))
}
pub const fn is_undefined(value: &Value) -> bool {
matches!(value, Value::Undefined)
}
pub fn as_string(value: &Value) -> Option<String> {
match *value {
Value::String(ref s) => Some(s.to_string()),
_ => None,
}
}
/// Borrow the inner string of a `Value::String` without cloning.
pub fn as_str(value: &Value) -> Option<&str> {
match *value {
Value::String(ref s) => Some(s),
_ => None,
}
}
/// Try to parse a string as a number for Azure Policy type coercion.
pub fn try_coerce_to_number(s: &str) -> Option<crate::number::Number> {
use core::str::FromStr as _;
// Try integer first, then float.
i64::from_str(s.trim())
.map(crate::number::Number::from)
.ok()
.or_else(|| {
f64::from_str(s.trim())
.map(crate::number::Number::from)
.ok()
})
}
// ── Path resolution ───────────────────────────────────────────────────
pub fn resolve_path(root: &Value, path: &str) -> Value {
let segments = tokenize_path(path);
let mut current = root.clone();
for segment in segments {
#[allow(clippy::pattern_type_mismatch)]
match &current {
Value::Object(map) => {
let mut next = None;
for (key, value) in map.iter() {
if let Value::String(ref key_str) = *key {
if strings::keys::eq(key_str, &segment) {
next = Some(value.clone());
break;
}
}
}
if let Some(value) = next {
current = value;
} else {
return Value::Undefined;
}
}
Value::Array(items) => {
let Ok(index) = segment.parse::<usize>() else {
return Value::Undefined;
};
let Some(value) = items.get(index) else {
return Value::Undefined;
};
current = value.clone();
}
_ => return Value::Undefined,
}
}
current
}
fn tokenize_path(path: &str) -> Vec<String> {
let mut segments = Vec::new();
let mut token = String::new();
let mut bracket = String::new();
let mut in_bracket = false;
for ch in path.chars() {
match ch {
'.' if !in_bracket => {
if !token.is_empty() {
segments.push(token.clone());
token.clear();
}
}
'[' => {
in_bracket = true;
if !token.is_empty() {
segments.push(token.clone());
token.clear();
}
}
']' => {
in_bracket = false;
let cleaned = bracket.trim_matches('"').trim_matches('\'').to_string();
if !cleaned.is_empty() {
segments.push(cleaned);
}
bracket.clear();
}
_ => {
if in_bracket {
bracket.push(ch);
} else {
token.push(ch);
}
}
}
}
if !token.is_empty() {
segments.push(token);
}
segments
}

View File

@@ -0,0 +1,65 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
//! Azure Policy builtins: operators, logic functions, and ARM template functions.
#![deny(
clippy::arithmetic_side_effects,
clippy::expect_used,
clippy::indexing_slicing,
clippy::panic,
clippy::shadow_unrelated,
clippy::unwrap_used,
clippy::missing_const_for_fn,
clippy::option_if_let_else,
clippy::semicolon_if_nothing_returned,
clippy::useless_let_if_seq
)]
pub mod helpers;
mod operators;
mod template_functions;
mod template_functions_collection;
mod template_functions_datetime;
mod template_functions_encoding;
mod template_functions_misc;
mod template_functions_numeric;
mod template_functions_string;
use crate::builtins;
/// Upper bound on the number of arguments accepted by variadic builtins.
///
/// ARM template expressions can pass many arguments to functions like
/// `min`, `max`, `union`, `intersection`, `format`, `createObject`, etc.
/// We register them with this cap instead of 0 so that the compiler/VM
/// arity checks accept real call sites.
pub(super) const MAX_VARIADIC_ARGS: u8 = 64;
pub fn register(m: &mut builtins::BuiltinsMap<&'static str, builtins::BuiltinFcn>) {
// Logic functions
m.insert(
"azure.policy.logic_all",
(operators::logic_all, MAX_VARIADIC_ARGS),
);
m.insert(
"azure.policy.logic_any",
(operators::logic_any, MAX_VARIADIC_ARGS),
);
m.insert("azure.policy.if", (operators::if_fn, 3));
// Field resolution
m.insert("azure.policy.resolve_field", (operators::resolve_field, 2));
// Parameter resolution with default-value fallback
m.insert("azure.policy.get_parameter", (operators::get_parameter, 3));
// ARM template functions
template_functions::register(m);
template_functions_string::register(m);
template_functions_encoding::register(m);
template_functions_collection::register(m);
template_functions_numeric::register(m);
template_functions_datetime::register(m);
template_functions_misc::register(m);
}

View File

@@ -0,0 +1,116 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
//! Azure Policy utility builtins: parameter resolution, field resolution,
//! logic_all (for ARM `and()`), and if().
//!
//! The 20 condition operators (equals, notEquals, greater, …, exists) and
//! the logic_not / logic_any combinators are now compiled as first-class
//! RVM instructions (`PolicyEquals`, `PolicyNot`, `AllOfStart`/`AnyOfStart`,
//! etc.) and no longer go through the builtin dispatch path.
use crate::ast::{Expr, Ref};
use crate::lexer::Span;
use crate::value::Value;
use anyhow::Result;
use super::helpers::{as_string, is_true, is_undefined, resolve_path};
// ── Parameter resolution ──────────────────────────────────────────────
/// `azure.policy.get_parameter(params, defaults, name)`
///
/// Returns `params[name]` if it exists and is not undefined; otherwise
/// falls back to `defaults[name]`. This lets the compiler bake parameter
/// default values into the program's literal table while still allowing
/// callers to override them via `input.parameters`.
pub(super) fn get_parameter(
_span: &Span,
_params: &[Ref<Expr>],
args: &[Value],
_strict: bool,
) -> Result<Value> {
#[allow(clippy::pattern_type_mismatch)]
let [params_obj, defaults_obj, name] = args
else {
return Ok(Value::Undefined);
};
// Try caller-supplied parameters first.
let val = &params_obj[name];
if !is_undefined(val) {
return Ok(val.clone());
}
// Fall back to compiled-in defaults.
Ok(defaults_obj[name].clone())
}
// ── Field resolution ──────────────────────────────────────────────────
pub(super) fn resolve_field(
_span: &Span,
_params: &[Ref<Expr>],
args: &[Value],
_strict: bool,
) -> Result<Value> {
#[allow(clippy::pattern_type_mismatch)]
let [resource, field_path] = args
else {
return Ok(Value::Undefined);
};
let Some(path) = as_string(field_path) else {
return Ok(Value::Undefined);
};
Ok(resolve_path(resource, &path))
}
// ── Logic functions ───────────────────────────────────────────────────
/// `azure.policy.logic_all(a, b, ...)`
///
/// Used by the ARM template `and()` function. Returns true iff every
/// argument is `Bool(true)`.
pub(super) fn logic_all(
_span: &Span,
_params: &[Ref<Expr>],
args: &[Value],
_strict: bool,
) -> Result<Value> {
Ok(Value::Bool(args.iter().all(is_true)))
}
/// `azure.policy.logic_any(a, b, ...)`
///
/// Used by the ARM template `or()` function. Returns true iff any
/// argument is `Bool(true)`.
pub(super) fn logic_any(
_span: &Span,
_params: &[Ref<Expr>],
args: &[Value],
_strict: bool,
) -> Result<Value> {
Ok(Value::Bool(args.iter().any(is_true)))
}
/// `azure.policy.if(cond, when_true, when_false)`
pub(super) fn if_fn(
_span: &Span,
_params: &[Ref<Expr>],
args: &[Value],
_strict: bool,
) -> Result<Value> {
#[allow(clippy::pattern_type_mismatch)]
let [cond, when_true, when_false] = args
else {
return Ok(Value::Undefined);
};
if is_true(cond) {
Ok(when_true.clone())
} else {
Ok(when_false.clone())
}
}

View File

@@ -0,0 +1,372 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
//! ARM template function builtins for Azure Policy expressions.
//!
//! Implements: split, empty, first, last, startsWith,
//! endsWith, int, string, bool, padLeft, ipRangeContains.
use crate::ast::{Expr, Ref};
use crate::builtins;
use crate::languages::azure_policy::strings::case_fold;
use crate::lexer::Span;
use crate::value::Value;
use alloc::string::{String, ToString as _};
use anyhow::Result;
use core::net::IpAddr;
use ipnet::IpNet;
use super::helpers::{as_str, try_coerce_to_number};
/// Truncate `f64` to `i64` (saturating semantics since Rust 1.45).
///
/// The standard library provides no `TryFrom<f64>` for `i64`, so a raw `as`
/// cast is the only option. Wrapping it in a named function keeps the rest
/// of the module free of `clippy::as_conversions` warnings.
#[expect(clippy::as_conversions, reason = "no TryFrom<f64> for i64 in std")]
const fn truncate_f64_to_i64(f: f64) -> i64 {
f as i64
}
pub(super) fn register(m: &mut builtins::BuiltinsMap<&'static str, builtins::BuiltinFcn>) {
m.insert("azure.policy.fn.split", (fn_split, 2));
m.insert("azure.policy.fn.empty", (fn_empty, 1));
m.insert("azure.policy.fn.first", (fn_first, 1));
m.insert("azure.policy.fn.last", (fn_last, 1));
m.insert("azure.policy.fn.starts_with", (fn_starts_with, 2));
m.insert("azure.policy.fn.ends_with", (fn_ends_with, 2));
m.insert("azure.policy.fn.int", (fn_int, 1));
m.insert("azure.policy.fn.string", (fn_string, 1));
m.insert("azure.policy.fn.bool", (fn_bool, 1));
m.insert("azure.policy.fn.pad_left", (fn_pad_left, 3));
m.insert(
"azure.policy.fn.ip_range_contains",
(fn_ip_range_contains, 2),
);
}
/// `split(inputString, delimiter)` → array of strings.
fn fn_split(_span: &Span, _params: &[Ref<Expr>], args: &[Value], _strict: bool) -> Result<Value> {
#[allow(clippy::pattern_type_mismatch)]
let [input_val, delim_val] = args
else {
return Ok(Value::Undefined);
};
let Some(input) = as_str(input_val) else {
return Ok(Value::Undefined);
};
// The delimiter argument can be a single string or an array of strings.
// When an array is provided, the input is split on ANY of the delimiters.
match *delim_val {
Value::String(ref delimiter) => {
if delimiter.is_empty() {
// Empty delimiter → no split; return input as single-element array.
return Ok(Value::from(alloc::vec![Value::from(input)]));
}
let parts: alloc::vec::Vec<Value> = input
.split(delimiter.as_ref())
.map(|s| Value::from(s.to_string()))
.collect();
Ok(Value::from(parts))
}
Value::Array(ref delimiters) => {
// Collect all string delimiters from the array.
let delims: alloc::vec::Vec<&str> = delimiters
.iter()
.filter_map(|v| match *v {
Value::String(ref s) => Some(s.as_ref()),
_ => None,
})
.collect();
if delims.is_empty() {
// No valid delimiters — return input as single-element array.
return Ok(Value::from(alloc::vec![Value::from(input)]));
}
// Scan the input and split on any matching delimiter.
// At each position, try delimiters longest-first to avoid
// substring-overlap issues.
let mut sorted_delims = delims.clone();
sorted_delims.sort_by_key(|d| core::cmp::Reverse(d.len()));
let mut parts = alloc::vec::Vec::new();
let mut current = String::new();
let bytes = input.as_bytes();
let mut i = 0;
while i < bytes.len() {
let mut matched = false;
for &d in &sorted_delims {
if !d.is_empty() && bytes.get(i..).is_some_and(|b| b.starts_with(d.as_bytes()))
{
parts.push(Value::from(core::mem::take(&mut current)));
i = i.wrapping_add(d.len());
matched = true;
break;
}
}
if !matched {
// Safe: we iterate byte-by-byte only when no delimiter matched.
// For correctness with multi-byte UTF-8, advance one char.
if let Some(ch) = input.get(i..).and_then(|s| s.chars().next()) {
current.push(ch);
i = i.wrapping_add(ch.len_utf8());
} else {
i = i.wrapping_add(1);
}
}
}
parts.push(Value::from(current));
Ok(Value::from(parts))
}
_ => Ok(Value::Undefined),
}
}
/// `empty(item)` → true if string/array/object is empty or value is null/undefined.
fn fn_empty(_span: &Span, _params: &[Ref<Expr>], args: &[Value], _strict: bool) -> Result<Value> {
let Some(arg) = args.first() else {
return Ok(Value::Bool(true));
};
let result = match *arg {
Value::String(ref s) => s.is_empty(),
Value::Array(ref a) => a.is_empty(),
Value::Object(ref o) => o.is_empty(),
Value::Null | Value::Undefined => true,
_ => false,
};
Ok(Value::Bool(result))
}
/// `first(arg)` → first element of array or first character of string.
///
/// Azure semantics: first of an empty string returns empty string,
/// first of an empty array returns null.
fn fn_first(_span: &Span, _params: &[Ref<Expr>], args: &[Value], _strict: bool) -> Result<Value> {
let Some(arg) = args.first() else {
return Ok(Value::Undefined);
};
match *arg {
Value::Array(ref a) => Ok(a.first().cloned().unwrap_or(Value::Null)),
Value::String(ref s) => Ok(s
.chars()
.next()
.map_or_else(|| Value::from(""), |ch| Value::from(ch.to_string()))),
_ => Ok(Value::Undefined),
}
}
/// `last(arg)` → last element of array or last character of string.
///
/// Azure semantics: last of an empty string returns empty string,
/// last of an empty array returns null.
fn fn_last(_span: &Span, _params: &[Ref<Expr>], args: &[Value], _strict: bool) -> Result<Value> {
let Some(arg) = args.first() else {
return Ok(Value::Undefined);
};
match *arg {
Value::Array(ref a) => Ok(a.last().cloned().unwrap_or(Value::Null)),
Value::String(ref s) => Ok(s
.chars()
.last()
.map_or_else(|| Value::from(""), |ch| Value::from(ch.to_string()))),
_ => Ok(Value::Undefined),
}
}
/// `startsWith(stringToSearch, stringToFind)` → bool (case-insensitive).
///
/// Uses full Unicode case folding via ICU4X.
fn fn_starts_with(
_span: &Span,
_params: &[Ref<Expr>],
args: &[Value],
_strict: bool,
) -> Result<Value> {
#[allow(clippy::pattern_type_mismatch)]
let [hay_val, needle_val] = args
else {
return Ok(Value::Bool(false));
};
let (Some(haystack), Some(needle)) = (as_str(hay_val), as_str(needle_val)) else {
return Ok(Value::Bool(false));
};
let fh = case_fold::fold(haystack);
let fn_ = case_fold::fold(needle);
Ok(Value::Bool(fh.starts_with(&*fn_)))
}
/// `endsWith(stringToSearch, stringToFind)` → bool (case-insensitive).
///
/// Uses full Unicode case folding via ICU4X.
fn fn_ends_with(
_span: &Span,
_params: &[Ref<Expr>],
args: &[Value],
_strict: bool,
) -> Result<Value> {
#[allow(clippy::pattern_type_mismatch)]
let [hay_val, needle_val] = args
else {
return Ok(Value::Bool(false));
};
let (Some(haystack), Some(needle)) = (as_str(hay_val), as_str(needle_val)) else {
return Ok(Value::Bool(false));
};
let fh = case_fold::fold(haystack);
let fn_ = case_fold::fold(needle);
Ok(Value::Bool(fh.ends_with(&*fn_)))
}
/// `int(valueToConvert)` → integer number.
fn fn_int(_span: &Span, _params: &[Ref<Expr>], args: &[Value], _strict: bool) -> Result<Value> {
let Some(arg) = args.first() else {
return Ok(Value::Undefined);
};
match *arg {
Value::Number(ref n) => {
// Truncate to integer.
n.as_i64()
.map(Value::from)
.or_else(|| n.as_f64().map(|f| Value::from(truncate_f64_to_i64(f))))
.map_or(Ok(Value::Undefined), Ok)
}
Value::String(ref s) => try_coerce_to_number(s).map_or(Ok(Value::Undefined), |n| {
n.as_i64()
.map(Value::from)
.or_else(|| n.as_f64().map(|f| Value::from(truncate_f64_to_i64(f))))
.map_or(Ok(Value::Undefined), Ok)
}),
_ => Ok(Value::Undefined),
}
}
/// `string(valueToConvert)` → string representation.
fn fn_string(_span: &Span, _params: &[Ref<Expr>], args: &[Value], _strict: bool) -> Result<Value> {
let Some(arg) = args.first() else {
return Ok(Value::Undefined);
};
match *arg {
Value::String(_) => Ok(arg.clone()),
Value::Bool(b) => Ok(Value::from(b.to_string())),
Value::Number(ref n) => Ok(Value::from(n.format_decimal())),
Value::Null => Ok(Value::from("null")),
Value::Undefined => Ok(Value::Undefined),
// For arrays and objects, produce JSON-style representation.
_ => Ok(Value::from(arg.to_string())),
}
}
/// `bool(value)` → boolean.
fn fn_bool(_span: &Span, _params: &[Ref<Expr>], args: &[Value], _strict: bool) -> Result<Value> {
let Some(arg) = args.first() else {
return Ok(Value::Undefined);
};
match *arg {
Value::Bool(_) => Ok(arg.clone()),
Value::String(ref s) => match s.to_lowercase().as_str() {
"true" | "1" => Ok(Value::Bool(true)),
"false" | "0" => Ok(Value::Bool(false)),
_ => Ok(Value::Undefined),
},
Value::Number(ref n) => n
.as_i64()
.map(|i| Value::Bool(i != 0))
.or_else(|| n.as_f64().map(|f| Value::Bool(f != 0.0)))
.map_or(Ok(Value::Undefined), Ok),
_ => Ok(Value::Undefined),
}
}
/// `padLeft(value, totalWidth, padChar)` → left-padded string.
fn fn_pad_left(
_span: &Span,
_params: &[Ref<Expr>],
args: &[Value],
_strict: bool,
) -> Result<Value> {
if args.len() < 2 || args.len() > 3 {
return Ok(Value::Undefined);
}
let Some(value) = args.first().and_then(as_str) else {
return Ok(Value::Undefined);
};
let width = args.get(1).and_then(|width_val| match *width_val {
Value::Number(ref n) => n
.as_i64()
.and_then(|x| usize::try_from(x).ok())
.or_else(|| {
n.as_f64()
.and_then(|x| usize::try_from(truncate_f64_to_i64(x)).ok())
}),
Value::String(ref s) => try_coerce_to_number(s).and_then(|n| {
n.as_i64()
.and_then(|x| usize::try_from(x).ok())
.or_else(|| {
n.as_f64()
.and_then(|x| usize::try_from(truncate_f64_to_i64(x)).ok())
})
}),
_ => None,
});
let Some(total_width) = width else {
return Ok(Value::Undefined);
};
let pad_char = args
.get(2)
.and_then(as_str)
.and_then(|s| s.chars().next())
.unwrap_or(' ');
let value_len = value.chars().count();
if value_len >= total_width {
return Ok(Value::from(value));
}
let pad_count = total_width.saturating_sub(value_len);
let mut padded = String::with_capacity(total_width);
for _ in 0..pad_count {
padded.push(pad_char);
}
padded.push_str(value);
Ok(Value::from(padded))
}
/// `ipRangeContains(range, targetRange)` → bool.
fn fn_ip_range_contains(
_span: &Span,
_params: &[Ref<Expr>],
args: &[Value],
_strict: bool,
) -> Result<Value> {
#[allow(clippy::pattern_type_mismatch)]
let [range_val, target_val] = args
else {
return Ok(Value::Bool(false));
};
let (Some(range), Some(target)) = (as_str(range_val), as_str(target_val)) else {
return Ok(Value::Bool(false));
};
let Ok(net) = range.parse::<IpNet>() else {
return Ok(Value::Bool(false));
};
if target.contains('/') {
let Ok(target_net) = target.parse::<IpNet>() else {
return Ok(Value::Bool(false));
};
return Ok(Value::Bool(net.contains(&target_net)));
}
let Ok(target_ip) = target.parse::<IpAddr>() else {
return Ok(Value::Bool(false));
};
Ok(Value::Bool(net.contains(&target_ip)))
}

View File

@@ -0,0 +1,317 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
//! ARM template collection function builtins for Azure Policy expressions.
//!
//! Implements: intersection, union, take, skip, range, array, coalesce, createObject.
use crate::ast::{Expr, Ref};
use crate::builtins;
use crate::lexer::Span;
use crate::value::Value;
use crate::Rc;
use alloc::collections::BTreeMap;
use alloc::vec::Vec;
use anyhow::Result;
use super::helpers::is_undefined;
pub(super) fn register(m: &mut builtins::BuiltinsMap<&'static str, builtins::BuiltinFcn>) {
m.insert(
"azure.policy.fn.intersection",
(fn_intersection, super::MAX_VARIADIC_ARGS),
);
m.insert(
"azure.policy.fn.union",
(fn_union, super::MAX_VARIADIC_ARGS),
);
m.insert("azure.policy.fn.take", (fn_take, 2));
m.insert("azure.policy.fn.skip", (fn_skip, 2));
m.insert("azure.policy.fn.range", (fn_range, 2));
m.insert("azure.policy.fn.array", (fn_array, 1));
m.insert(
"azure.policy.fn.coalesce",
(fn_coalesce, super::MAX_VARIADIC_ARGS),
);
m.insert(
"azure.policy.fn.create_object",
(fn_create_object, super::MAX_VARIADIC_ARGS),
);
}
/// `intersection(arg1, arg2, ...)` → elements common to all arrays, or keys common
/// to all objects.
///
/// For arrays: returns elements present in every input array.
/// For objects: returns keys (with values from the first) present in every input.
fn fn_intersection(
_span: &Span,
_params: &[Ref<Expr>],
args: &[Value],
_strict: bool,
) -> Result<Value> {
let Some(first) = args.first() else {
return Ok(Value::Undefined);
};
let rest = args.get(1..).unwrap_or_default();
match *first {
Value::Array(ref first) => {
// Intersection of arrays: keep elements from first that appear in all others.
let mut result: Vec<Value> = first.as_ref().clone();
for arg in rest {
let Value::Array(ref other) = *arg else {
return Ok(Value::Undefined);
};
result.retain(|item| other.contains(item));
}
Ok(Value::from(result))
}
Value::Object(ref first) => {
// Intersection of objects: keep key-value pairs from the first
// object only when the key exists in every other object AND
// the value is equal across all of them.
let mut result: BTreeMap<Value, Value> = first.as_ref().clone();
for arg in rest {
let Value::Object(ref other) = *arg else {
return Ok(Value::Undefined);
};
result.retain(|k, v| other.get(k).is_some_and(|ov| *ov == *v));
}
Ok(Value::Object(Rc::new(result)))
}
_ => Ok(Value::Undefined),
}
}
/// `union(arg1, arg2, ...)` → all unique elements from arrays, or merged objects.
///
/// For arrays: returns distinct elements across all arrays.
/// For objects: merges all objects (later values overwrite earlier for same key).
fn fn_union(_span: &Span, _params: &[Ref<Expr>], args: &[Value], _strict: bool) -> Result<Value> {
let Some(first) = args.first() else {
return Ok(Value::Undefined);
};
match *first {
Value::Array(_) => {
// Union of arrays: collect unique elements preserving first-seen order.
let mut seen = alloc::collections::BTreeSet::<&Value>::new();
let mut result = Vec::new();
for arg in args {
let Value::Array(ref arr) = *arg else {
return Ok(Value::Undefined);
};
for item in arr.iter() {
if seen.insert(item) {
result.push(item.clone());
}
}
}
Ok(Value::from(result))
}
Value::Object(_) => {
// Union of objects: recursive merge. Nested objects are merged
// recursively; all other types (including arrays) use last-writer-wins.
let mut result = BTreeMap::<Value, Value>::new();
for arg in args {
let Value::Object(ref obj) = *arg else {
return Ok(Value::Undefined);
};
for (k, v) in obj.iter() {
#[allow(clippy::needless_borrowed_reference)]
let merged = match (result.get(k), v) {
(Some(&Value::Object(ref prev)), &Value::Object(ref next)) => {
merge_objects(prev, next)
}
_ => v.clone(),
};
result.insert(k.clone(), merged);
}
}
Ok(Value::Object(Rc::new(result)))
}
_ => Ok(Value::Undefined),
}
}
/// `take(originalValue, numberToTake)` → first N elements of array or chars of string.
fn fn_take(_span: &Span, _params: &[Ref<Expr>], args: &[Value], _strict: bool) -> Result<Value> {
#[allow(clippy::pattern_type_mismatch)]
let [original, count_val] = args
else {
return Ok(Value::Undefined);
};
let count = extract_usize(count_val).unwrap_or(0);
match *original {
Value::Array(ref arr) => {
let n = count.min(arr.len());
Ok(Value::from(arr.get(..n).unwrap_or_default().to_vec()))
}
Value::String(ref s) => {
let taken: alloc::string::String = s.chars().take(count).collect();
Ok(Value::from(taken))
}
_ => Ok(Value::Undefined),
}
}
/// `skip(originalValue, numberToSkip)` → array/string after skipping N elements.
fn fn_skip(_span: &Span, _params: &[Ref<Expr>], args: &[Value], _strict: bool) -> Result<Value> {
#[allow(clippy::pattern_type_mismatch)]
let [original, count_val] = args
else {
return Ok(Value::Undefined);
};
let count = extract_usize(count_val).unwrap_or(0);
match *original {
Value::Array(ref arr) => {
let n = count.min(arr.len());
Ok(Value::from(arr.get(n..).unwrap_or_default().to_vec()))
}
Value::String(ref s) => {
let skipped: alloc::string::String = s.chars().skip(count).collect();
Ok(Value::from(skipped))
}
_ => Ok(Value::Undefined),
}
}
/// `range(startIndex, count)` → array of integers starting at startIndex.
///
/// Azure limits: count ≤ 10000, startIndex + count ≤ 2147483647.
fn fn_range(_span: &Span, _params: &[Ref<Expr>], args: &[Value], _strict: bool) -> Result<Value> {
#[allow(clippy::pattern_type_mismatch)]
let [start_val, count_val] = args
else {
return Ok(Value::Undefined);
};
let (Some(start), Some(count)) = (extract_i64(start_val), extract_i64(count_val)) else {
return Ok(Value::Undefined);
};
if count < 0 {
return Ok(Value::Undefined);
}
// Enforce Azure-documented limits.
if count > 10_000 {
anyhow::bail!("range: count ({count}) exceeds maximum of 10000");
}
let end = start
.checked_add(count)
.ok_or_else(|| anyhow::anyhow!("range overflow"))?;
if end > 2_147_483_647 {
anyhow::bail!("range: startIndex + count ({end}) exceeds maximum of 2147483647");
}
let mut result = Vec::with_capacity(usize::try_from(count).unwrap_or(0));
for i in 0..count {
let val = start
.checked_add(i)
.ok_or_else(|| anyhow::anyhow!("range overflow"))?;
result.push(Value::from(val));
}
Ok(Value::from(result))
}
/// `array(convertToArray)` → wraps a single value in an array.
///
/// If the input is already an array, returns it as-is.
fn fn_array(_span: &Span, _params: &[Ref<Expr>], args: &[Value], _strict: bool) -> Result<Value> {
let Some(arg) = args.first() else {
return Ok(Value::from(Vec::<Value>::new()));
};
match *arg {
Value::Array(_) => Ok(arg.clone()),
_ => Ok(Value::from(alloc::vec![arg.clone()])),
}
}
/// `coalesce(arg1, arg2, ...)` → first non-null, non-undefined argument.
fn fn_coalesce(
_span: &Span,
_params: &[Ref<Expr>],
args: &[Value],
_strict: bool,
) -> Result<Value> {
for arg in args {
if !is_undefined(arg) && !matches!(arg, Value::Null) {
return Ok(arg.clone());
}
}
Ok(Value::Null)
}
/// `createObject(key1, value1, key2, value2, ...)` → object from key-value pairs.
fn fn_create_object(
_span: &Span,
_params: &[Ref<Expr>],
args: &[Value],
_strict: bool,
) -> Result<Value> {
if !args.len().is_multiple_of(2) {
anyhow::bail!(
"createObject: expected an even number of arguments (key-value pairs), \
but received {}",
args.len()
);
}
let mut map = BTreeMap::<Value, Value>::new();
for pair in args.chunks(2) {
#[allow(clippy::pattern_type_mismatch)]
if let [key, value] = pair {
map.insert(key.clone(), value.clone());
}
}
Ok(Value::Object(Rc::new(map)))
}
// ── Helpers ───────────────────────────────────────────────────────────
/// Recursively merge two objects. Nested objects are merged; everything
/// else (including arrays) uses the value from `incoming`.
fn merge_objects(base: &BTreeMap<Value, Value>, overlay: &BTreeMap<Value, Value>) -> Value {
let mut result = base.clone();
for (k, v) in overlay {
#[allow(clippy::needless_borrowed_reference)]
let merged = match (result.get(k), v) {
(Some(&Value::Object(ref prev)), &Value::Object(ref next)) => merge_objects(prev, next),
_ => v.clone(),
};
result.insert(k.clone(), merged);
}
Value::Object(Rc::new(result))
}
fn extract_usize(v: &Value) -> Option<usize> {
match *v {
Value::Number(ref n) => n
.as_i64()
.and_then(|x| usize::try_from(x).ok())
.or_else(|| n.as_f64().and_then(|x| usize::try_from(f64_as_i64(x)).ok())),
_ => None,
}
}
fn extract_i64(v: &Value) -> Option<i64> {
match *v {
Value::Number(ref n) => n.as_i64().or_else(|| n.as_f64().map(f64_as_i64)),
_ => None,
}
}
/// Deliberate truncating conversion from `f64` → `i64`.
#[expect(clippy::as_conversions)]
const fn f64_as_i64(x: f64) -> i64 {
x as i64
}

View File

@@ -0,0 +1,687 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
//! ARM template date/time builtins: dateTimeAdd, dateTimeFromEpoch,
//! dateTimeToEpoch, addDays.
//!
//! `utcNow()` is handled in the compiler (loaded from context), not here.
use crate::ast::{Expr, Ref};
use crate::builtins;
use crate::lexer::Span;
use crate::value::Value;
use alloc::string::{String, ToString as _};
use alloc::vec::Vec;
use anyhow::Result;
use core::fmt::Write as _;
use chrono::{DateTime, Duration, FixedOffset, Utc};
use super::helpers::as_str;
pub(super) fn register(m: &mut builtins::BuiltinsMap<&'static str, builtins::BuiltinFcn>) {
m.insert("azure.policy.fn.date_time_add", (fn_date_time_add, 3));
m.insert(
"azure.policy.fn.date_time_from_epoch",
(fn_date_time_from_epoch, 1),
);
m.insert(
"azure.policy.fn.date_time_to_epoch",
(fn_date_time_to_epoch, 1),
);
m.insert("azure.policy.fn.add_days", (fn_add_days, 0));
}
// ── ISO 8601 datetime parsing ─────────────────────────────────────────
/// Parse an ISO 8601 / RFC 3339 datetime string.
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));
}
if let Ok(dt) = DateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S%.f%:z") {
return Some((dt, DateTimeStyle::SpaceOffset));
}
// 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));
}
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));
}
}
// 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));
}
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));
}
}
// 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));
}
// 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));
}
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));
}
None
}
/// Format a datetime as ISO 8601 string. UTC datetimes use the `Z` suffix
/// (matching Azure's documented output), while offset datetimes keep their
/// 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
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`.
///
/// Supports: `P[nY][nM][nD][T[nH][nM][nS]]`
/// Examples: `P1D`, `PT1H`, `P1Y2M3DT4H5M6S`, `PT30M`, `-P1D`
///
/// Note: months/years are approximated (1 month = 30 days, 1 year = 365 days)
/// since chrono::Duration is absolute. ARM template behavior matches this.
fn parse_iso8601_duration(s: &str) -> Option<Duration> {
let (s, negative) = s.strip_prefix('-').map_or((s, false), |rest| (rest, true));
let s = s.strip_prefix('P')?;
let mut total_seconds: i64 = 0;
let mut in_time = false;
let mut num_buf = String::new();
for ch in s.chars() {
match ch {
'T' => {
if !num_buf.is_empty() {
return None;
}
in_time = true;
}
'0'..='9' | '.' => {
num_buf.push(ch);
}
'Y' if !in_time => {
let n: f64 = num_buf.parse().ok()?;
total_seconds = total_seconds.checked_add(f64_as_i64(n * 365.0 * 86400.0))?;
num_buf.clear();
}
'M' if !in_time => {
// Months in date part
let n: f64 = num_buf.parse().ok()?;
total_seconds = total_seconds.checked_add(f64_as_i64(n * 30.0 * 86400.0))?;
num_buf.clear();
}
'W' if !in_time => {
let n: f64 = num_buf.parse().ok()?;
total_seconds = total_seconds.checked_add(f64_as_i64(n * 7.0 * 86400.0))?;
num_buf.clear();
}
'D' if !in_time => {
let n: f64 = num_buf.parse().ok()?;
total_seconds = total_seconds.checked_add(f64_as_i64(n * 86400.0))?;
num_buf.clear();
}
'H' if in_time => {
let n: f64 = num_buf.parse().ok()?;
total_seconds = total_seconds.checked_add(f64_as_i64(n * 3600.0))?;
num_buf.clear();
}
'M' if in_time => {
// Minutes in time part
let n: f64 = num_buf.parse().ok()?;
total_seconds = total_seconds.checked_add(f64_as_i64(n * 60.0))?;
num_buf.clear();
}
'S' if in_time => {
let n: f64 = num_buf.parse().ok()?;
total_seconds = total_seconds.checked_add(f64_as_i64(n))?;
num_buf.clear();
}
_ => return None,
}
}
if !num_buf.is_empty() {
return None;
}
let dur = Duration::seconds(if negative {
total_seconds.checked_neg()?
} else {
total_seconds
});
Some(dur)
}
// ── Builtin functions ─────────────────────────────────────────────────
/// `dateTimeAdd(base, duration, format?)` → add ISO 8601 duration to datetime.
///
/// 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.
fn fn_date_time_add(
_span: &Span,
_params: &[Ref<Expr>],
args: &[Value],
_strict: bool,
) -> Result<Value> {
let Some(base_str) = args.first().and_then(as_str) else {
return Ok(Value::Undefined);
};
let Some(duration_str) = args.get(1).and_then(as_str) else {
return Ok(Value::Undefined);
};
let Some((base_dt, style)) = parse_datetime_styled(base_str) else {
return Ok(Value::Undefined);
};
let Some(duration) = parse_iso8601_duration(duration_str) else {
return Ok(Value::Undefined);
};
let result = base_dt
.checked_add_signed(duration)
.ok_or_else(|| anyhow::anyhow!("dateTimeAdd: datetime overflow"))?;
let output = match args.get(2).and_then(as_str) {
Some(fmt) => format_datetime_dotnet(&result, fmt)?,
None => format_datetime_styled(&result, style),
};
Ok(Value::from(output))
}
/// `dateTimeFromEpoch(epoch)` → ISO 8601 UTC datetime string from Unix epoch.
fn fn_date_time_from_epoch(
_span: &Span,
_params: &[Ref<Expr>],
args: &[Value],
_strict: bool,
) -> Result<Value> {
let Some(epoch) = args.first().and_then(extract_i64) else {
return Ok(Value::Undefined);
};
let Some(dt) = DateTime::<Utc>::from_timestamp(epoch, 0) else {
return Ok(Value::Undefined);
};
// Always UTC, so use Z suffix.
Ok(Value::from(dt.format("%Y-%m-%dT%H:%M:%SZ").to_string()))
}
/// `dateTimeToEpoch(dateTime)` → Unix epoch seconds from ISO 8601 string.
fn fn_date_time_to_epoch(
_span: &Span,
_params: &[Ref<Expr>],
args: &[Value],
_strict: bool,
) -> Result<Value> {
let Some(s) = args.first().and_then(as_str) else {
return Ok(Value::Undefined);
};
let Some(dt) = parse_datetime(s) else {
return Ok(Value::Undefined);
};
Ok(Value::from(dt.timestamp()))
}
/// `addDays(dateTime, numberOfDays)` → ISO 8601 datetime with days added.
///
/// Very common in real Azure Policy definitions (e.g., key expiry checks).
fn fn_add_days(
_span: &Span,
_params: &[Ref<Expr>],
args: &[Value],
_strict: bool,
) -> Result<Value> {
let Some(base_str) = args.first().and_then(as_str) else {
return Ok(Value::Undefined);
};
let Some(days) = args.get(1).and_then(extract_i64) else {
return Ok(Value::Undefined);
};
let Some(base_dt) = parse_datetime(base_str) else {
return Ok(Value::Undefined);
};
let duration = Duration::days(days);
let result = base_dt
.checked_add_signed(duration)
.ok_or_else(|| anyhow::anyhow!("addDays: datetime overflow"))?;
Ok(Value::from(format_datetime(&result)))
}
// ── Helpers ───────────────────────────────────────────────────────────
fn extract_i64(v: &Value) -> Option<i64> {
match *v {
Value::Number(ref n) => n.as_i64(),
_ => None,
}
}
/// Deliberate truncating conversion from `f64` → `i64`.
#[expect(clippy::as_conversions)]
const fn f64_as_i64(x: f64) -> i64 {
x as i64
}
/// Segments produced by parsing a .NET custom datetime format string.
enum FmtSegment {
/// A chrono format string.
Chrono(String),
/// Fractional seconds, truncated to `n` digits (1..=7).
Frac(usize),
/// First character of AM/PM (the .NET `t` specifier).
AmPmShort,
/// Timezone offset hours, no leading zero (the .NET `z` specifier).
TzHoursNoPad,
/// Timezone offset hours, with leading zero (the .NET `zz` specifier).
TzHoursPad,
}
/// Result of trying to interpret a format string as a .NET standard specifier.
enum StandardFormat {
/// Expanded custom format string.
Expansion(String),
/// A standard specifier that requires UTC conversion before formatting.
UtcNormalized(String),
/// Not a single-letter standard specifier (treat as custom format).
NotStandard,
}
/// Format a datetime using a .NET-style date/time format string.
///
/// Handles both standard format strings (single character like `d`, `G`, `o`)
/// and custom format strings (multi-token patterns like `yyyy-MM-dd`).
///
/// # Compatibility note
///
/// This is an *approximation* of `System.DateTime.ToString()` targeting the
/// invariant culture only, which is what Azure Policy uses in practice. The
/// segment-based architecture (`FmtSegment` + `dotnet_to_segments`) covers
/// the specifiers exercised by real-world policy definitions, but
/// culture-sensitive corners (localised day/month names, era designators,
/// calendar systems, etc.) are deliberately omitted. Pulling in a full
/// ICU/globalisation stack would be disproportionate for this use case.
/// If a specific .NET format corner is needed later, it can be added
/// incrementally by extending the segment parser.
fn format_datetime_dotnet(dt: &DateTime<FixedOffset>, dotnet_fmt: &str) -> Result<String> {
// Check for standard format specifiers and determine the effective
// custom format and the datetime to format against.
let (effective_fmt_owned, format_dt);
let effective_fmt = match resolve_standard_format(dotnet_fmt)? {
StandardFormat::Expansion(s) => {
effective_fmt_owned = s;
&effective_fmt_owned
}
StandardFormat::UtcNormalized(s) => {
// Convert to UTC before formatting (e.g. the 'u' specifier).
format_dt = dt.with_timezone(&Utc).fixed_offset();
effective_fmt_owned = s;
let is_utc = true;
let segments = dotnet_to_segments(&effective_fmt_owned, is_utc);
return Ok(render_segments(&format_dt, &segments));
}
StandardFormat::NotStandard => dotnet_fmt,
};
let is_utc = dt.offset().local_minus_utc() == 0;
let segments = dotnet_to_segments(effective_fmt, is_utc);
Ok(render_segments(dt, &segments))
}
/// Render pre-parsed format segments against a datetime value.
fn render_segments(dt: &DateTime<FixedOffset>, segments: &[FmtSegment]) -> String {
let mut out = String::new();
for seg in segments {
match *seg {
FmtSegment::Chrono(ref fmt) => {
out.push_str(&dt.format(fmt).to_string());
}
FmtSegment::Frac(n) => {
// %f gives 9-digit nanoseconds; take the first n digits.
let nanos = dt.format("%f").to_string();
let truncated: String = nanos.chars().take(n).collect();
out.push_str(&truncated);
}
FmtSegment::AmPmShort => {
let full = dt.format("%p").to_string();
if let Some(c) = full.chars().next() {
out.push(c);
}
}
FmtSegment::TzHoursNoPad => {
let hours = dt.offset().local_minus_utc() / 3600;
if hours >= 0 {
out.push('+');
}
let _ = write!(out, "{hours}");
}
FmtSegment::TzHoursPad => {
let secs = dt.offset().local_minus_utc();
let hours = secs / 3600;
if secs >= 0 {
let _ = write!(out, "+{hours:02}");
} else {
let _ = write!(out, "-{:02}", hours.wrapping_neg());
}
}
}
}
out
}
/// Resolve a .NET standard date/time format specifier.
///
/// Returns the appropriate `StandardFormat` variant:
/// - `Expansion` for standard specifiers that can be expanded to custom tokens.
/// - `UtcNormalized` for specifiers that require UTC conversion first.
/// - `NotStandard` when the string is a multi-character custom format.
///
/// Single-letter strings that are *not* a recognised standard specifier are
/// also mapped to their equivalent custom token (via the .NET `%`-prefix
/// rule), so that e.g. `"U"` does not silently produce a literal `U`.
///
/// Reference: <https://learn.microsoft.com/dotnet/standard/base-types/standard-date-and-time-format-strings>
fn resolve_standard_format(fmt: &str) -> Result<StandardFormat> {
if fmt.len() != 1 {
return Ok(StandardFormat::NotStandard);
}
Ok(match fmt {
// Short date (invariant culture: MM/dd/yyyy)
"d" => StandardFormat::Expansion("MM/dd/yyyy".into()),
// Long date (invariant: dddd, dd MMMM yyyy)
"D" => StandardFormat::Expansion("dddd, dd MMMM yyyy".into()),
// Short time (invariant: HH:mm)
"t" => StandardFormat::Expansion("HH:mm".into()),
// Long time (invariant: HH:mm:ss)
"T" => StandardFormat::Expansion("HH:mm:ss".into()),
// General short time (short date + short time)
"g" => StandardFormat::Expansion("MM/dd/yyyy HH:mm".into()),
// General long time (short date + long time)
"G" => StandardFormat::Expansion("MM/dd/yyyy HH:mm:ss".into()),
// Month/day (invariant: MMMM dd)
"M" | "m" => StandardFormat::Expansion("MMMM dd".into()),
// Round-trip / ISO 8601 (o / O are identical)
"o" | "O" => StandardFormat::Expansion("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fffffffK".into()),
// RFC1123 (invariant: ddd, dd MMM yyyy HH:mm:ss 'GMT') — requires UTC conversion
"R" | "r" => StandardFormat::UtcNormalized("ddd, dd MMM yyyy HH':'mm':'ss 'GMT'".into()),
// Sortable (ISO 8601 without offset)
"s" => StandardFormat::Expansion("yyyy'-'MM'-'dd'T'HH':'mm':'ss".into()),
// Universal sortable (UTC, trailing Z) — requires UTC conversion
"u" => StandardFormat::UtcNormalized("yyyy'-'MM'-'dd HH':'mm':'ss'Z'".into()),
// Full date/time (UTC) — requires UTC conversion
"U" => StandardFormat::UtcNormalized("dddd, dd MMMM yyyy HH:mm:ss".into()),
// Year/month (invariant: yyyy MMMM)
"Y" | "y" => StandardFormat::Expansion("yyyy MMMM".into()),
// Full date/short time
"f" => StandardFormat::Expansion("dddd, dd MMMM yyyy HH:mm".into()),
// Full date/long time
"F" => StandardFormat::Expansion("dddd, dd MMMM yyyy HH:mm:ss".into()),
// Not a standard specifier → error. In .NET, passing an
// unrecognised single-letter string to DateTime.ToString() throws
// FormatException rather than silently echoing the character.
_ => anyhow::bail!(
"dateTimeAdd: unrecognised standard format specifier '{}'",
fmt
),
})
}
/// Parse a .NET custom datetime format string into segments.
///
/// Consecutive chrono-compatible tokens are batched into a single `Chrono`
/// segment; tokens that need custom logic produce their own segment.
fn dotnet_to_segments(fmt: &str, is_utc: bool) -> Vec<FmtSegment> {
let mut segments: Vec<FmtSegment> = Vec::new();
let mut chrono_buf = String::new();
let chars: Vec<char> = fmt.chars().collect();
let len = chars.len();
let mut i: usize = 0;
macro_rules! flush {
() => {
if !chrono_buf.is_empty() {
segments.push(FmtSegment::Chrono(core::mem::take(&mut chrono_buf)));
}
};
}
while i < len {
let ch = chars.get(i).copied().unwrap_or('\0');
let remaining = len.saturating_sub(i);
match ch {
// Escaped literal
'\\' if remaining > 1 => {
i = i.wrapping_add(1);
let next = chars.get(i).copied().unwrap_or('\0');
chrono_buf.push(next);
i = i.wrapping_add(1);
}
// Quoted literal
'\'' => {
i = i.wrapping_add(1);
while i < len {
let c = chars.get(i).copied().unwrap_or('\0');
if c == '\'' {
i = i.wrapping_add(1);
break;
}
chrono_buf.push(c);
i = i.wrapping_add(1);
}
}
// Year
'y' if remaining >= 4 && matches_run(&chars, i, 'y', 4) => {
chrono_buf.push_str("%Y");
i = i.wrapping_add(4);
}
'y' if remaining >= 2 && matches_run(&chars, i, 'y', 2) => {
chrono_buf.push_str("%y");
i = i.wrapping_add(2);
}
// Month
'M' if remaining >= 4 && matches_run(&chars, i, 'M', 4) => {
chrono_buf.push_str("%B");
i = i.wrapping_add(4);
}
'M' if remaining >= 3 && matches_run(&chars, i, 'M', 3) => {
chrono_buf.push_str("%b");
i = i.wrapping_add(3);
}
'M' if remaining >= 2 && matches_run(&chars, i, 'M', 2) => {
chrono_buf.push_str("%m");
i = i.wrapping_add(2);
}
'M' => {
chrono_buf.push_str("%-m");
i = i.wrapping_add(1);
}
// Day
'd' if remaining >= 4 && matches_run(&chars, i, 'd', 4) => {
chrono_buf.push_str("%A");
i = i.wrapping_add(4);
}
'd' if remaining >= 3 && matches_run(&chars, i, 'd', 3) => {
chrono_buf.push_str("%a");
i = i.wrapping_add(3);
}
'd' if remaining >= 2 && matches_run(&chars, i, 'd', 2) => {
chrono_buf.push_str("%d");
i = i.wrapping_add(2);
}
'd' => {
chrono_buf.push_str("%-d");
i = i.wrapping_add(1);
}
// 24-hour
'H' if remaining >= 2 && matches_run(&chars, i, 'H', 2) => {
chrono_buf.push_str("%H");
i = i.wrapping_add(2);
}
'H' => {
chrono_buf.push_str("%-H");
i = i.wrapping_add(1);
}
// 12-hour
'h' if remaining >= 2 && matches_run(&chars, i, 'h', 2) => {
chrono_buf.push_str("%I");
i = i.wrapping_add(2);
}
'h' => {
chrono_buf.push_str("%-I");
i = i.wrapping_add(1);
}
// Minute
'm' if remaining >= 2 && matches_run(&chars, i, 'm', 2) => {
chrono_buf.push_str("%M");
i = i.wrapping_add(2);
}
'm' => {
chrono_buf.push_str("%-M");
i = i.wrapping_add(1);
}
// Second
's' if remaining >= 2 && matches_run(&chars, i, 's', 2) => {
chrono_buf.push_str("%S");
i = i.wrapping_add(2);
}
's' => {
chrono_buf.push_str("%-S");
i = i.wrapping_add(1);
}
// Fractions of second — consume the full run of 'f' chars
'f' => {
let mut count: usize = 0;
while i < len && chars.get(i).copied() == Some('f') {
count = count.wrapping_add(1);
i = i.wrapping_add(1);
}
flush!();
// Clamp to 9 (nanosecond precision from chrono).
segments.push(FmtSegment::Frac(count.min(9)));
}
// AM/PM
't' if remaining >= 2 && matches_run(&chars, i, 't', 2) => {
chrono_buf.push_str("%p");
i = i.wrapping_add(2);
}
't' => {
flush!();
segments.push(FmtSegment::AmPmShort);
i = i.wrapping_add(1);
}
// Timezone: K in .NET → offset or Z
'K' => {
if is_utc {
chrono_buf.push('Z');
} else {
chrono_buf.push_str("%:z");
}
i = i.wrapping_add(1);
}
// Timezone offset zzz → full offset +00:00
'z' if remaining >= 3 && matches_run(&chars, i, 'z', 3) => {
chrono_buf.push_str("%:z");
i = i.wrapping_add(3);
}
// zz → offset hours with leading zero
'z' if remaining >= 2 && matches_run(&chars, i, 'z', 2) => {
flush!();
segments.push(FmtSegment::TzHoursPad);
i = i.wrapping_add(2);
}
// z → offset hours without leading zero
'z' => {
flush!();
segments.push(FmtSegment::TzHoursNoPad);
i = i.wrapping_add(1);
}
// Literal characters (including T, :, -, etc.)
_ => {
chrono_buf.push(ch);
i = i.wrapping_add(1);
}
}
}
flush!();
segments
}
/// Check whether the slice starting at `start` contains at least `count`
/// consecutive occurrences of `ch`.
fn matches_run(chars: &[char], start: usize, ch: char, count: usize) -> bool {
(0..count).all(|offset| chars.get(start.wrapping_add(offset)).copied() == Some(ch))
}

View File

@@ -0,0 +1,329 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
//! ARM template encoding builtins: base64, base64ToString, base64ToJson,
//! uri, uriComponent, uriComponentToString, dataUri, dataUriToString.
use crate::ast::{Expr, Ref};
use crate::builtins;
use crate::lexer::Span;
use crate::value::Value;
use alloc::string::{String, ToString as _};
use alloc::vec::Vec;
use anyhow::Result;
use super::helpers::as_str;
pub(super) fn register(m: &mut builtins::BuiltinsMap<&'static str, builtins::BuiltinFcn>) {
m.insert("azure.policy.fn.base64", (fn_base64, 1));
m.insert("azure.policy.fn.base64_to_string", (fn_base64_to_string, 1));
m.insert("azure.policy.fn.base64_to_json", (fn_base64_to_json, 1));
m.insert("azure.policy.fn.uri", (fn_uri, 2));
m.insert("azure.policy.fn.uri_component", (fn_uri_component, 1));
m.insert(
"azure.policy.fn.uri_component_to_string",
(fn_uri_component_to_string, 1),
);
m.insert("azure.policy.fn.data_uri", (fn_data_uri, 1));
m.insert(
"azure.policy.fn.data_uri_to_string",
(fn_data_uri_to_string, 1),
);
}
// ── Base64 helpers (pure implementation, no external deps) ────────────
const BASE64_CHARS: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
fn base64_encode(input: &[u8]) -> String {
let cap = input.len().div_ceil(3).saturating_mul(4);
let mut result = String::with_capacity(cap);
for chunk in input.chunks(3) {
let b0 = u32::from(*chunk.first().unwrap_or(&0));
let b1 = u32::from(*chunk.get(1).unwrap_or(&0));
let b2 = u32::from(*chunk.get(2).unwrap_or(&0));
let triple = (b0 << 16) | (b1 << 8) | b2;
let idx0 = usize::try_from((triple >> 18) & 0x3F).unwrap_or(0);
let idx1 = usize::try_from((triple >> 12) & 0x3F).unwrap_or(0);
let idx2 = usize::try_from((triple >> 6) & 0x3F).unwrap_or(0);
let idx3 = usize::try_from(triple & 0x3F).unwrap_or(0);
result.push(char::from(*BASE64_CHARS.get(idx0).unwrap_or(&b'A')));
result.push(char::from(*BASE64_CHARS.get(idx1).unwrap_or(&b'A')));
if chunk.len() > 1 {
result.push(char::from(*BASE64_CHARS.get(idx2).unwrap_or(&b'A')));
} else {
result.push('=');
}
if chunk.len() > 2 {
result.push(char::from(*BASE64_CHARS.get(idx3).unwrap_or(&b'A')));
} else {
result.push('=');
}
}
result
}
const fn base64_decode_byte(c: u8) -> Option<u8> {
match c {
b'A'..=b'Z' => Some(c.wrapping_sub(b'A')),
b'a'..=b'z' => Some(c.wrapping_sub(b'a').wrapping_add(26)),
b'0'..=b'9' => Some(c.wrapping_sub(b'0').wrapping_add(52)),
b'+' => Some(62),
b'/' => Some(63),
_ => None,
}
}
fn base64_decode(input: &str) -> Option<Vec<u8>> {
let input = input.trim();
if input.is_empty() {
return Some(Vec::new());
}
let bytes: Vec<u8> = input
.bytes()
.filter(|&b| b != b'\n' && b != b'\r')
.collect();
if !bytes.len().is_multiple_of(4) {
return None;
}
let mut result = Vec::with_capacity((bytes.len() / 4).saturating_mul(3));
for chunk in bytes.chunks(4) {
let [c0, c1, c2, c3] = <[u8; 4]>::try_from(chunk).ok()?;
let a = base64_decode_byte(c0)?;
let b = base64_decode_byte(c1)?;
let triple = u32::from(a) << 18
| u32::from(b) << 12
| if c2 != b'=' {
u32::from(base64_decode_byte(c2)?) << 6
} else {
0
}
| if c3 != b'=' {
u32::from(base64_decode_byte(c3)?)
} else {
0
};
result.push(u8::try_from((triple >> 16) & 0xFF).unwrap_or(0));
if c2 != b'=' {
result.push(u8::try_from((triple >> 8) & 0xFF).unwrap_or(0));
}
if c3 != b'=' {
result.push(u8::try_from(triple & 0xFF).unwrap_or(0));
}
}
Some(result)
}
/// `base64(inputString)` → base64-encoded string.
fn fn_base64(_span: &Span, _params: &[Ref<Expr>], args: &[Value], _strict: bool) -> Result<Value> {
let Some(s) = args.first().and_then(as_str) else {
return Ok(Value::Undefined);
};
Ok(Value::from(base64_encode(s.as_bytes())))
}
/// `base64ToString(base64Value)` → decoded UTF-8 string.
fn fn_base64_to_string(
_span: &Span,
_params: &[Ref<Expr>],
args: &[Value],
_strict: bool,
) -> Result<Value> {
let Some(s) = args.first().and_then(as_str) else {
return Ok(Value::Undefined);
};
let Some(decoded) = base64_decode(s) else {
return Ok(Value::Undefined);
};
String::from_utf8(decoded).map_or_else(|_| Ok(Value::Undefined), |text| Ok(Value::from(text)))
}
/// `base64ToJson(base64Value)` → parsed JSON value from base64-encoded string.
fn fn_base64_to_json(
_span: &Span,
_params: &[Ref<Expr>],
args: &[Value],
_strict: bool,
) -> Result<Value> {
let Some(s) = args.first().and_then(as_str) else {
return Ok(Value::Undefined);
};
let Some(decoded) = base64_decode(s) else {
return Ok(Value::Undefined);
};
let Ok(text) = String::from_utf8(decoded) else {
return Ok(Value::Undefined);
};
Value::from_json_str(&text).map_or_else(|_| Ok(Value::Undefined), Ok)
}
// ── URI helpers (pure implementation) ─────────────────────────────────
const fn is_unreserved(b: u8) -> bool {
b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b == b'.' || b == b'~'
}
fn percent_encode(s: &str) -> String {
let mut result = String::with_capacity(s.len());
for &b in s.as_bytes() {
if is_unreserved(b) {
result.push(char::from(b));
} else {
// b >> 4 is in 0..=15 and b & 0x0F is in 0..=15, so from_digit
// always returns Some for radix 16.
result.push('%');
result.push(
core::char::from_digit(u32::from(b >> 4), 16)
.unwrap_or('0')
.to_ascii_uppercase(),
);
result.push(
core::char::from_digit(u32::from(b & 0x0F), 16)
.unwrap_or('0')
.to_ascii_uppercase(),
);
}
}
result
}
fn percent_decode(s: &str) -> Option<String> {
let bytes = s.as_bytes();
let mut result = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if *bytes.get(i)? == b'%' {
// Require exactly two hex digits after '%'; reject incomplete escapes.
let hi = char::from(*bytes.get(i.checked_add(1)?)?).to_digit(16)?;
let lo = char::from(*bytes.get(i.checked_add(2)?)?).to_digit(16)?;
result.push(u8::try_from(hi.checked_mul(16)?.checked_add(lo)?).ok()?);
i = i.checked_add(3)?;
} else {
result.push(*bytes.get(i)?);
i = i.checked_add(1)?;
}
}
String::from_utf8(result).ok()
}
/// `uri(baseUri, relativeUri)` → combined URI.
fn fn_uri(_span: &Span, _params: &[Ref<Expr>], args: &[Value], _strict: bool) -> Result<Value> {
if args.len() != 2 {
return Ok(Value::Undefined);
}
let (Some(base), Some(relative)) =
(args.first().and_then(as_str), args.get(1).and_then(as_str))
else {
return Ok(Value::Undefined);
};
// Simple URI combination following Azure semantics.
let combined = if relative.starts_with("http://") || relative.starts_with("https://") {
// Relative is an absolute URL — use it directly.
relative.to_string()
} else if base.ends_with('/') {
alloc::format!("{}{}", base, relative.trim_start_matches('/'))
} else {
// Find the end of the authority (scheme://host). The path starts
// after the third '/' (e.g. https://example.com/path → slash at
// position after "com"). If there is no path component at all
// (e.g. "https://example.com"), just append.
let scheme_end = base.find("://").map(|p| p.wrapping_add(3)).unwrap_or(0);
let path_slash = base.get(scheme_end..).and_then(|rest| rest.find('/'));
path_slash.map_or_else(
|| {
// Authority-only base (no path) — append with slash.
alloc::format!("{}/{}", base, relative.trim_start_matches('/'))
},
|offset| {
// There is a path — replace the last segment.
let abs_pos = scheme_end.wrapping_add(offset);
let last_slash = base
.get(abs_pos..)
.and_then(|p| p.rfind('/'))
.map(|o| abs_pos.wrapping_add(o));
let cut = last_slash.unwrap_or(abs_pos);
alloc::format!(
"{}/{}",
base.get(..cut).unwrap_or(base),
relative.trim_start_matches('/')
)
},
)
};
Ok(Value::from(combined))
}
/// `uriComponent(stringToEncode)` → percent-encoded string.
fn fn_uri_component(
_span: &Span,
_params: &[Ref<Expr>],
args: &[Value],
_strict: bool,
) -> Result<Value> {
let Some(s) = args.first().and_then(as_str) else {
return Ok(Value::Undefined);
};
Ok(Value::from(percent_encode(s)))
}
/// `uriComponentToString(uriEncodedString)` → decoded string.
fn fn_uri_component_to_string(
_span: &Span,
_params: &[Ref<Expr>],
args: &[Value],
_strict: bool,
) -> Result<Value> {
let Some(s) = args.first().and_then(as_str) else {
return Ok(Value::Undefined);
};
percent_decode(s).map_or_else(|| Ok(Value::Undefined), |decoded| Ok(Value::from(decoded)))
}
/// `dataUri(stringToConvert)` → data URI (text/plain;charset=utf8).
fn fn_data_uri(
_span: &Span,
_params: &[Ref<Expr>],
args: &[Value],
_strict: bool,
) -> Result<Value> {
let Some(s) = args.first().and_then(as_str) else {
return Ok(Value::Undefined);
};
let encoded = base64_encode(s.as_bytes());
Ok(Value::from(alloc::format!(
"data:text/plain;charset=utf8;base64,{}",
encoded
)))
}
/// `dataUriToString(dataUriToConvert)` → decoded string from data URI.
fn fn_data_uri_to_string(
_span: &Span,
_params: &[Ref<Expr>],
args: &[Value],
_strict: bool,
) -> Result<Value> {
let Some(s) = args.first().and_then(as_str) else {
return Ok(Value::Undefined);
};
// Expected format: data:<mediatype>;base64,<data>
let Some(rest) = s.strip_prefix("data:") else {
return Ok(Value::Undefined);
};
// Find the base64 data after the last comma.
let Some(comma_pos) = rest.rfind(',') else {
return Ok(Value::Undefined);
};
let b64_data = rest.get(comma_pos.saturating_add(1)..).unwrap_or("");
let Some(decoded) = base64_decode(b64_data) else {
return Ok(Value::Undefined);
};
String::from_utf8(decoded).map_or_else(|_| Ok(Value::Undefined), |text| Ok(Value::from(text)))
}

View File

@@ -0,0 +1,197 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
//! ARM template miscellaneous function builtins for Azure Policy expressions.
//!
//! Implements: json, join, items, indexFromEnd, tryGet, tryIndexFromEnd.
use crate::ast::{Expr, Ref};
use crate::builtins;
use crate::lexer::Span;
use crate::value::Value;
use crate::Rc;
use alloc::collections::BTreeMap;
use alloc::string::{String, ToString as _};
use alloc::vec::Vec;
use anyhow::Result;
use super::helpers::as_str;
pub(super) fn register(m: &mut builtins::BuiltinsMap<&'static str, builtins::BuiltinFcn>) {
m.insert("azure.policy.fn.json", (fn_json, 1));
m.insert("azure.policy.fn.join", (fn_join, 2));
m.insert("azure.policy.fn.items", (fn_items, 1));
m.insert("azure.policy.fn.index_from_end", (fn_index_from_end, 2));
m.insert("azure.policy.fn.try_get", (fn_try_get, 2));
m.insert(
"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).
}
// ── json ──────────────────────────────────────────────────────────────
/// `json(arg)` → parses a JSON string into a typed value.
///
/// `json('null')` returns null, `json('{"a":1}')` returns an object, etc.
fn fn_json(_span: &Span, _params: &[Ref<Expr>], args: &[Value], _strict: bool) -> Result<Value> {
let Some(s) = args.first().and_then(as_str) else {
return Ok(Value::Undefined);
};
Value::from_json_str(s).map_err(|e| anyhow::anyhow!("json(): {}", e))
}
// ── join ──────────────────────────────────────────────────────────────
/// `join(inputArray, delimiter)` → joins array elements with delimiter.
fn fn_join(_span: &Span, _params: &[Ref<Expr>], args: &[Value], _strict: bool) -> Result<Value> {
#[allow(clippy::pattern_type_mismatch)]
let [arr_val, delim_val] = args
else {
return Ok(Value::Undefined);
};
let Value::Array(ref arr) = *arr_val else {
return Ok(Value::Undefined);
};
let Some(delim) = as_str(delim_val) else {
return Ok(Value::Undefined);
};
let parts: Vec<String> = arr
.iter()
.map(|v| match *v {
Value::String(ref s) => s.to_string(),
Value::Number(ref n) => n.format_decimal(),
Value::Bool(b) => b.to_string(),
Value::Null => "null".to_string(),
_ => v.to_string(),
})
.collect();
Ok(Value::from(parts.join(delim)))
}
// ── items ─────────────────────────────────────────────────────────────
/// `items(object)` → array of `{"key": k, "value": v}` pairs.
fn fn_items(_span: &Span, _params: &[Ref<Expr>], args: &[Value], _strict: bool) -> Result<Value> {
let Some(first) = args.first() else {
return Ok(Value::Undefined);
};
let Value::Object(ref obj) = *first else {
return Ok(Value::Undefined);
};
let mut result = Vec::with_capacity(obj.len());
for (k, v) in obj.as_ref() {
let mut entry = BTreeMap::<Value, Value>::new();
entry.insert(Value::from("key"), k.clone());
entry.insert(Value::from("value"), v.clone());
result.push(Value::Object(Rc::new(entry)));
}
Ok(Value::Array(Rc::new(result)))
}
// ── indexFromEnd ──────────────────────────────────────────────────────
/// `indexFromEnd(sourceArray, reverseIndex)` → element at 1-based reverse index.
///
/// `indexFromEnd([a,b,c,d], 2)` returns `c` (2nd from end).
fn fn_index_from_end(
_span: &Span,
_params: &[Ref<Expr>],
args: &[Value],
_strict: bool,
) -> Result<Value> {
#[allow(clippy::pattern_type_mismatch)]
let [arr_val, idx_val] = args
else {
return Ok(Value::Undefined);
};
let Value::Array(ref arr) = *arr_val else {
return Ok(Value::Undefined);
};
let Some(rev_idx) = extract_usize(idx_val) else {
return Ok(Value::Undefined);
};
if rev_idx == 0 || rev_idx > arr.len() {
anyhow::bail!("indexFromEnd: reverse index {} out of bounds", rev_idx);
}
let pos = arr
.len()
.checked_sub(rev_idx)
.ok_or_else(|| anyhow::anyhow!("indexFromEnd: arithmetic overflow"))?;
arr.get(pos)
.cloned()
.ok_or_else(|| anyhow::anyhow!("indexFromEnd: index out of bounds"))
}
// ── tryGet ────────────────────────────────────────────────────────────
/// `tryGet(itemToTest, keyOrIndex)` → value at key/index, or null if missing.
fn fn_try_get(_span: &Span, _params: &[Ref<Expr>], args: &[Value], _strict: bool) -> Result<Value> {
#[allow(clippy::pattern_type_mismatch)]
let [item, key_or_idx] = args
else {
return Ok(Value::Undefined);
};
match *item {
Value::Object(ref obj) => {
// Property lookup by string key
Ok(obj.get(key_or_idx).cloned().unwrap_or(Value::Null))
}
Value::Array(ref arr) => {
// Index lookup
let Some(idx) = extract_usize(key_or_idx) else {
return Ok(Value::Null);
};
Ok(arr.get(idx).cloned().unwrap_or(Value::Null))
}
_ => Ok(Value::Null),
}
}
// ── tryIndexFromEnd ───────────────────────────────────────────────────
/// `tryIndexFromEnd(sourceArray, reverseIndex)` → element or null if out of bounds.
fn fn_try_index_from_end(
_span: &Span,
_params: &[Ref<Expr>],
args: &[Value],
_strict: bool,
) -> Result<Value> {
#[allow(clippy::pattern_type_mismatch)]
let [arr_val, idx_val] = args
else {
return Ok(Value::Undefined);
};
let Value::Array(ref arr) = *arr_val else {
return Ok(Value::Null);
};
let Some(rev_idx) = extract_usize(idx_val) else {
return Ok(Value::Null);
};
if rev_idx == 0 || rev_idx > arr.len() {
return Ok(Value::Null);
}
let pos = arr.len().saturating_sub(rev_idx);
Ok(arr.get(pos).cloned().unwrap_or(Value::Null))
}
// ── Helpers ───────────────────────────────────────────────────────────
fn extract_usize(v: &Value) -> Option<usize> {
match *v {
Value::Number(ref n) => n
.as_i64()
.and_then(|x| usize::try_from(x).ok())
.or_else(|| n.as_f64().and_then(|x| usize::try_from(f64_to_i64(x)).ok())),
_ => None,
}
}
#[expect(clippy::as_conversions)]
const fn f64_to_i64(x: f64) -> i64 {
x as i64
}

View File

@@ -0,0 +1,155 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
//! ARM template numeric function builtins for Azure Policy expressions.
//!
//! Implements: min, max, float.
//!
//! Note: `sub`, `mul`, `div`, `mod` are compiled directly to native RVM
//! instructions (`Sub`, `Mul`, `Div`, `Mod`) and do not need builtins.
use crate::ast::{Expr, Ref};
use crate::builtins;
use crate::lexer::Span;
use crate::value::Value;
use anyhow::Result;
use super::helpers::try_coerce_to_number;
pub(super) fn register(m: &mut builtins::BuiltinsMap<&'static str, builtins::BuiltinFcn>) {
m.insert("azure.policy.fn.min", (fn_min, super::MAX_VARIADIC_ARGS));
m.insert("azure.policy.fn.max", (fn_max, super::MAX_VARIADIC_ARGS));
m.insert("azure.policy.fn.float", (fn_float, 1));
m.insert("azure.policy.fn.int_div", (fn_int_div, 2));
m.insert("azure.policy.fn.int_mod", (fn_int_mod, 2));
}
/// `min(arg1, arg2, ...)` or `min(intArray)` → smallest integer/number.
///
/// Accepts either:
/// - Multiple integer arguments: `min(1, 2, 3)` → `1`
/// - A single array argument: `min([1, 2, 3])` → `1`
fn fn_min(_span: &Span, _params: &[Ref<Expr>], args: &[Value], _strict: bool) -> Result<Value> {
let values = if args.len() == 1 {
args.first().map_or(args, |first| match *first {
Value::Array(ref arr) => arr.as_ref().as_slice(),
_ => args,
})
} else {
args
};
if values.is_empty() {
return Ok(Value::Undefined);
}
let mut result: Option<&Value> = None;
for v in values {
match *v {
Value::Number(_) => {
result = Some(match result {
Some(current) if v >= current => current,
_ => v,
});
}
_ => return Ok(Value::Undefined),
}
}
Ok(result.cloned().unwrap_or(Value::Undefined))
}
/// `max(arg1, arg2, ...)` or `max(intArray)` → largest integer/number.
///
/// Same overloading as `min`.
fn fn_max(_span: &Span, _params: &[Ref<Expr>], args: &[Value], _strict: bool) -> Result<Value> {
let values = if args.len() == 1 {
args.first().map_or(args, |first| match *first {
Value::Array(ref arr) => arr.as_ref().as_slice(),
_ => args,
})
} else {
args
};
if values.is_empty() {
return Ok(Value::Undefined);
}
let mut result: Option<&Value> = None;
for v in values {
match *v {
Value::Number(_) => {
result = Some(match result {
Some(current) if v <= current => current,
_ => v,
});
}
_ => return Ok(Value::Undefined),
}
}
Ok(result.cloned().unwrap_or(Value::Undefined))
}
/// `float(value)` → floating-point number.
fn fn_float(_span: &Span, _params: &[Ref<Expr>], args: &[Value], _strict: bool) -> Result<Value> {
let Some(arg) = args.first() else {
return Ok(Value::Undefined);
};
match *arg {
Value::Number(ref n) => Ok(n.as_f64().map_or(Value::Undefined, Value::from)),
Value::String(ref s) => Ok(try_coerce_to_number(s)
.and_then(|n| n.as_f64())
.map_or(Value::Undefined, Value::from)),
_ => Ok(Value::Undefined),
}
}
/// `div(operand1, operand2)` → integer division (truncating).
///
/// ARM template `div()` performs integer division, unlike the RVM `Div`
/// instruction which may produce floats for non-evenly-divisible operands.
fn fn_int_div(_span: &Span, _params: &[Ref<Expr>], args: &[Value], _strict: bool) -> Result<Value> {
#[allow(clippy::pattern_type_mismatch)]
let [a, b] = args
else {
return Ok(Value::Undefined);
};
let (a, b) = (extract_i64(a), extract_i64(b));
match (a, b) {
(Some(a), Some(b)) => a
.checked_div(b)
.map_or(Ok(Value::Undefined), |r| Ok(Value::from(r))),
_ => Ok(Value::Undefined),
}
}
/// `mod(operand1, operand2)` → integer modulo.
fn fn_int_mod(_span: &Span, _params: &[Ref<Expr>], args: &[Value], _strict: bool) -> Result<Value> {
#[allow(clippy::pattern_type_mismatch)]
let [a, b] = args
else {
return Ok(Value::Undefined);
};
let (a, b) = (extract_i64(a), extract_i64(b));
match (a, b) {
(Some(a), Some(b)) => a
.checked_rem(b)
.map_or(Ok(Value::Undefined), |r| Ok(Value::from(r))),
_ => Ok(Value::Undefined),
}
}
fn extract_i64(v: &Value) -> Option<i64> {
match *v {
Value::Number(ref n) => n.as_i64().or_else(|| n.as_f64().map(f64_to_i64)),
_ => None,
}
}
#[expect(clippy::as_conversions)]
const fn f64_to_i64(x: f64) -> i64 {
x as i64
}

View File

@@ -0,0 +1,474 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
//! ARM template string function builtins for Azure Policy expressions.
//!
//! Implements: indexOf, lastIndexOf, trim, format.
use crate::ast::{Expr, Ref};
use crate::builtins;
use crate::languages::azure_policy::strings::case_fold;
use crate::lexer::Span;
use crate::value::Value;
use alloc::string::{String, ToString as _};
use alloc::vec::Vec;
use anyhow::Result;
use super::helpers::as_str;
pub(super) fn register(m: &mut builtins::BuiltinsMap<&'static str, builtins::BuiltinFcn>) {
m.insert("azure.policy.fn.index_of", (fn_index_of, 2));
m.insert("azure.policy.fn.last_index_of", (fn_last_index_of, 2));
m.insert("azure.policy.fn.trim", (fn_trim, 1));
m.insert(
"azure.policy.fn.format",
(fn_format, super::MAX_VARIADIC_ARGS),
);
}
/// `indexOf(stringToSearch, stringToFind)` → zero-based character index, or -1 if not found.
///
/// Azure documents this as case-insensitive. Uses full Unicode case folding
/// via ICU4X (`case_fold::fold`) and returns a *character* index (not byte).
fn fn_index_of(
_span: &Span,
_params: &[Ref<Expr>],
args: &[Value],
_strict: bool,
) -> Result<Value> {
#[allow(clippy::pattern_type_mismatch)]
let [hay_val, needle_val] = args
else {
return Ok(Value::Undefined);
};
let (Some(haystack), Some(needle)) = (as_str(hay_val), as_str(needle_val)) else {
return Ok(Value::Undefined);
};
Ok(Value::from(case_insensitive_index_of(haystack, needle)))
}
/// `lastIndexOf(stringToSearch, stringToFind)` → zero-based character index, or -1.
///
/// Azure documents this as case-insensitive. Uses full Unicode case folding
/// and returns a *character* index.
fn fn_last_index_of(
_span: &Span,
_params: &[Ref<Expr>],
args: &[Value],
_strict: bool,
) -> Result<Value> {
#[allow(clippy::pattern_type_mismatch)]
let [hay_val, needle_val] = args
else {
return Ok(Value::Undefined);
};
let (Some(haystack), Some(needle)) = (as_str(hay_val), as_str(needle_val)) else {
return Ok(Value::Undefined);
};
Ok(Value::from(case_insensitive_last_index_of(
haystack, needle,
)))
}
/// Case-insensitive first-occurrence search returning a *UTF-16 code-unit* index.
///
/// Azure/ARM string functions are .NET-based — indices are UTF-16 code units,
/// not Rust `char` (Unicode scalar) positions. We track the UTF-16 offset
/// in `fold_with_char_map` so that surrogate-pair characters are counted
/// correctly.
///
/// Case-folds both strings using ICU4X and searches in the folded domain.
/// The haystack is folded in a single pass that simultaneously builds a
/// byte-to-UTF-16-offset mapping, avoiding a redundant second fold.
fn case_insensitive_index_of(haystack: &str, needle: &str) -> i64 {
let folded_needle = case_fold::fold(needle);
if folded_needle.is_empty() {
return 0;
}
let (folded_hay, byte_to_utf16) = fold_with_char_map(haystack);
if folded_needle.len() > folded_hay.len() {
return -1;
}
folded_hay
.find(&*folded_needle)
.and_then(|byte_pos| byte_to_utf16.get(byte_pos).copied())
.and_then(|ci| i64::try_from(ci).ok())
.unwrap_or(-1)
}
/// Case-insensitive last-occurrence search returning a *UTF-16 code-unit* index.
fn case_insensitive_last_index_of(haystack: &str, needle: &str) -> i64 {
let folded_needle = case_fold::fold(needle);
if folded_needle.is_empty() {
return i64::try_from(haystack.encode_utf16().count()).unwrap_or(-1);
}
let (folded_hay, byte_to_utf16) = fold_with_char_map(haystack);
if folded_needle.len() > folded_hay.len() {
return -1;
}
folded_hay
.rfind(&*folded_needle)
.and_then(|byte_pos| byte_to_utf16.get(byte_pos).copied())
.and_then(|ci| i64::try_from(ci).ok())
.unwrap_or(-1)
}
/// Case-fold a string one character at a time, returning both the folded
/// string and a byte-to-UTF-16-offset map in a single pass.
///
/// Each source character contributes `ch.len_utf16()` to the running
/// UTF-16 offset, so non-BMP codepoints (surrogate pairs) are counted
/// as two units — matching .NET `String.IndexOf` semantics.
///
/// # Performance note
///
/// This function allocates a folded copy of the haystack and a parallel
/// `Vec<usize>` mapping every folded byte back to a source UTF-16 offset.
/// The allocation is inherent to Unicode case folding — you need the folded
/// string to search in it. For the string sizes typical in Azure Policy
/// templates (field names, resource type strings) this is negligible. If
/// profiling ever shows this as a hot path on very large inputs, a streaming
/// fold-and-match approach could replace it, but that is speculative
/// optimisation at this point.
fn fold_with_char_map(s: &str) -> (String, Vec<usize>) {
let mut folded = String::with_capacity(s.len());
let mut map = Vec::with_capacity(s.len());
let mut utf16_offset: usize = 0;
for (byte_idx, ch) in s.char_indices() {
let ch_len = ch.len_utf8();
let end = byte_idx.wrapping_add(ch_len);
let ch_slice = s.get(byte_idx..end).unwrap_or("");
let folded_ch = case_fold::fold(ch_slice);
folded.push_str(&folded_ch);
for _ in 0..folded_ch.len() {
map.push(utf16_offset);
}
utf16_offset = utf16_offset.wrapping_add(ch.len_utf16());
}
(folded, map)
}
/// `trim(stringToTrim)` → string with leading/trailing whitespace removed.
fn fn_trim(_span: &Span, _params: &[Ref<Expr>], args: &[Value], _strict: bool) -> Result<Value> {
let Some(arg) = args.first() else {
return Ok(Value::Undefined);
};
let Some(s) = as_str(arg) else {
return Ok(Value::Undefined);
};
Ok(Value::from(s.trim().to_string()))
}
/// `format(formatString, arg0, arg1, ...)` → formatted string.
///
/// ARM template format matches `System.String.Format` conventions:
/// - `{index[,alignment][:formatString]}` placeholders
/// - `{{` and `}}` are escaped literal braces
/// - Alignment: positive = right-padded, negative = left-padded
/// - Numeric format strings: N/n (number with thousands), D/d (decimal),
/// X/x (hex), F/f (fixed-point), etc.
fn fn_format(_span: &Span, _params: &[Ref<Expr>], args: &[Value], _strict: bool) -> Result<Value> {
if args.is_empty() {
return Ok(Value::Undefined);
}
let Some(first) = args.first() else {
return Ok(Value::Undefined);
};
let Some(template) = as_str(first) else {
return Ok(Value::Undefined);
};
let format_args: Vec<String> = args
.get(1..)
.unwrap_or_default()
.iter()
.map(|v| match *v {
Value::String(ref s) => s.to_string(),
Value::Number(ref n) => n.format_decimal(),
Value::Bool(true) => "True".into(),
Value::Bool(false) => "False".into(),
Value::Null => String::new(),
_ => v.to_string(),
})
.collect();
let format_args_values = args.get(1..).unwrap_or_default();
let mut result = String::new();
let chars: Vec<char> = template.chars().collect();
let len = chars.len();
let mut i = 0;
while i < len {
let ch = chars.get(i).copied().unwrap_or('\0');
match ch {
'{' => {
// Check for escaped brace {{
if chars.get(i.wrapping_add(1)).copied() == Some('{') {
result.push('{');
i = i.wrapping_add(2);
continue;
}
// Parse placeholder: {index[,alignment][:formatString]}
i = i.wrapping_add(1); // skip '{'
let mut index_str = String::new();
while i < len && chars.get(i).copied().unwrap_or('\0').is_ascii_digit() {
index_str.push(chars.get(i).copied().unwrap_or('\0'));
i = i.wrapping_add(1);
}
if index_str.is_empty() {
anyhow::bail!(
"format: invalid placeholder at position {}; expected '{{index}}'",
i.wrapping_sub(1)
);
}
let idx: usize = index_str.parse().map_err(|_| {
anyhow::anyhow!(
"format: invalid placeholder index '{}' at position {}; expected a non-negative integer within range",
index_str,
i.wrapping_sub(index_str.len()).wrapping_sub(1)
)
})?;
// Optional alignment
let alignment: i32 = if chars.get(i).copied() == Some(',') {
i = i.wrapping_add(1); // skip ','
let mut align_str = String::new();
while i < len {
let c = chars.get(i).copied().unwrap_or('\0');
if c == ':' || c == '}' {
break;
}
align_str.push(c);
i = i.wrapping_add(1);
}
let trimmed = align_str.trim();
if trimmed.is_empty() {
anyhow::bail!(
"format: empty alignment value in placeholder at position {}",
i.wrapping_sub(align_str.len()).wrapping_sub(1)
);
}
trimmed.parse().map_err(|_| {
anyhow::anyhow!(
"format: invalid alignment '{}' in placeholder; expected an integer",
trimmed
)
})?
} else {
0
};
// Optional format specifier
let mut fmt_spec = String::new();
if chars.get(i).copied() == Some(':') {
i = i.wrapping_add(1); // skip ':'
while i < len && chars.get(i).copied().unwrap_or('\0') != '}' {
fmt_spec.push(chars.get(i).copied().unwrap_or('\0'));
i = i.wrapping_add(1);
}
}
// Require closing '}'
if chars.get(i).copied() == Some('}') {
i = i.wrapping_add(1);
} else {
anyhow::bail!(
"format: unmatched opening brace '{{{{' at position {}; \
expected closing '}}}}'.",
i.wrapping_sub(index_str.len()).wrapping_sub(1)
);
}
// Format the argument — error if the index is out of range,
// matching System.String.Format semantics.
let Some(raw) = format_args.get(idx).cloned() else {
anyhow::bail!(
"format: placeholder {{{idx}}} references argument index {idx}, \
but only {} argument(s) were supplied",
format_args.len()
);
};
let formatted = if fmt_spec.is_empty() {
raw
} else {
apply_format_spec(&raw, &fmt_spec, format_args_values.get(idx))?
};
// Apply alignment
apply_alignment(&mut result, &formatted, alignment)?;
}
'}' => {
// Escaped }} → literal '}'
if chars.get(i.wrapping_add(1)).copied() == Some('}') {
result.push('}');
i = i.wrapping_add(2);
} else {
anyhow::bail!("format: unmatched closing brace '}}' at position {i}");
}
}
_ => {
result.push(ch);
i = i.wrapping_add(1);
}
}
}
Ok(Value::from(result))
}
/// Apply a .NET-style format specifier to a value string.
fn apply_format_spec(raw: &str, spec: &str, value: Option<&Value>) -> Result<String> {
let spec_char = spec.chars().next().unwrap_or('G');
let precision: Option<usize> = spec.get(1..).and_then(|s| s.parse().ok());
// Try to get numeric value for numeric formatting
let int_val = value.and_then(|v| match *v {
Value::Number(ref n) => n.as_i64(),
_ => None,
});
let float_val = value.and_then(|v| match *v {
Value::Number(ref n) => n.as_f64(),
_ => None,
});
Ok(match spec_char {
// Fixed-point
'F' | 'f' => {
let prec = precision.unwrap_or(2);
float_val.map_or_else(|| raw.to_string(), |f| alloc::format!("{f:.prec$}"))
}
// Number with thousands separator
'N' | 'n' => {
let prec = precision.unwrap_or(2);
float_val.map_or_else(|| raw.to_string(), |f| format_with_thousands(f, prec))
}
// Decimal (integer)
'D' | 'd' => {
let width = precision.unwrap_or(0);
int_val.map_or_else(
|| raw.to_string(),
|n| {
if n < 0 {
alloc::format!("-{:0>width$}", n.unsigned_abs())
} else {
alloc::format!("{n:0>width$}")
}
},
)
}
// Hexadecimal
'X' => {
let width = precision.unwrap_or(0);
int_val.map_or_else(
|| raw.to_string(),
|n| alloc::format!("{:0>width$}", alloc::format!("{n:X}")),
)
}
'x' => {
let width = precision.unwrap_or(0);
int_val.map_or_else(
|| raw.to_string(),
|n| alloc::format!("{:0>width$}", alloc::format!("{n:x}")),
)
}
// Percent
'P' | 'p' => {
let prec = precision.unwrap_or(2);
float_val.map_or_else(
|| raw.to_string(),
|f| {
let pct = f * 100.0;
alloc::format!("{pct:.prec$} %")
},
)
}
// Unknown specifier: error for numeric values (.NET throws FormatException),
// pass through for non-numeric.
_ => {
if int_val.is_some() || float_val.is_some() {
anyhow::bail!("format: invalid numeric format specifier '{spec_char}'");
}
raw.to_string()
}
})
}
/// Format a number with thousands separators and fixed decimal places.
fn format_with_thousands(value: f64, precision: usize) -> String {
let formatted = alloc::format!("{value:.precision$}");
let (int_part, dec_part) = formatted.split_once('.').unwrap_or((&formatted, ""));
let negative = int_part.starts_with('-');
let digits = if negative {
int_part.get(1..).unwrap_or("")
} else {
int_part
};
let mut with_commas = String::new();
for (i, ch) in digits.chars().rev().enumerate() {
if i > 0 && i % 3 == 0 {
with_commas.push(',');
}
with_commas.push(ch);
}
let with_commas: String = with_commas.chars().rev().collect();
let mut result = String::new();
if negative {
result.push('-');
}
result.push_str(&with_commas);
if precision > 0 {
result.push('.');
result.push_str(dec_part);
}
result
}
/// Maximum alignment width to prevent excessive memory allocation from
/// user-controlled format strings (e.g. `{0,1000000000}`).
const MAX_ALIGNMENT_WIDTH: usize = 10_000;
/// Apply alignment (padding) to a formatted value.
fn apply_alignment(result: &mut String, formatted: &str, alignment: i32) -> Result<()> {
if alignment == 0 {
result.push_str(formatted);
} else {
let width = usize::try_from(alignment.unsigned_abs()).unwrap_or(usize::MAX);
if width > MAX_ALIGNMENT_WIDTH {
anyhow::bail!(
"format: alignment width {width} exceeds maximum allowed ({MAX_ALIGNMENT_WIDTH})"
);
}
let char_len = formatted.chars().count();
if char_len >= width {
result.push_str(formatted);
} else {
let padding = width.saturating_sub(char_len);
if alignment > 0 {
// Right-align: pad on left
for _ in 0..padding {
result.push(' ');
}
result.push_str(formatted);
} else {
// Left-align: pad on right
result.push_str(formatted);
for _ in 0..padding {
result.push(' ');
}
}
}
}
Ok(())
}

View File

@@ -16,6 +16,8 @@
mod aggregates;
mod arrays;
#[cfg(feature = "azure_policy")]
pub mod azure_policy;
mod bitwise;
pub mod comparison;
mod conversions;
@@ -104,6 +106,8 @@ lazy_static! {
#[cfg(feature = "semver")]
semver::register(&mut m);
//rego::register(&mut m);
#[cfg(feature = "azure_policy")]
azure_policy::register(&mut m);
#[cfg(feature = "opa-runtime")]
opa::register(&mut m);
tracing::register(&mut m);

View File

@@ -0,0 +1,6 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
//! Azure Policy language support.
pub mod strings;

View File

@@ -0,0 +1,223 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
//! Full Unicode case folding for Azure Policy string value comparisons.
//!
//! Azure Policy evaluates most condition operators (`equals`, `notEquals`,
//! `contains`, `like`, `in`, …) using .NET's `InvariantCultureIgnoreCase`.
//! Under .NET 5+ this is backed by ICU, performing *full* Unicode case folding:
//!
//! - ß → ss (German sharp s)
//! - ffi → ffi (Latin ligatures)
//! - Σ/σ/ς → σ (Greek sigma variants)
//!
//! We use [`icu_casemap::CaseMapper`] with compiled data, which provides the
//! same ICU case folding tables that .NET uses internally.
//!
//! # Usage
//!
//! ```rust,ignore
//! use regorus::languages::azure_policy::strings::case_fold;
//!
//! // Equality
//! assert!(case_fold::eq("Straße", "STRASSE"));
//! assert!(case_fold::eq("hello", "HELLO"));
//!
//! // Ordering
//! assert_eq!(case_fold::cmp("alpha", "BETA"), std::cmp::Ordering::Less);
//!
//! // Folded form (for hashing, indexing, etc.)
//! let folded: std::borrow::Cow<str> = case_fold::fold("Straße");
//! assert_eq!(&*folded, "strasse");
//! ```
use alloc::borrow::Cow;
use core::cmp::Ordering;
use icu_casemap::CaseMapperBorrowed;
static CASE_MAPPER: CaseMapperBorrowed<'static> = CaseMapperBorrowed::new();
/// Return the full-case-folded form of `s`.
///
/// The result is a [`Cow::Borrowed`] when `s` is already in folded form
/// (common for ASCII lowercase), avoiding allocation.
///
/// This is the canonical form for case-insensitive hashing and indexing.
pub fn fold(s: &str) -> Cow<'_, str> {
CASE_MAPPER.fold_string(s)
}
/// Case-insensitive equality using full Unicode case folding.
///
/// Equivalent to .NET `String.Equals(a, b, StringComparison.InvariantCultureIgnoreCase)`.
///
/// # Fast path
///
/// If both strings are plain ASCII, falls back to `eq_ignore_ascii_case`
/// which avoids allocation entirely.
pub fn eq(a: &str, b: &str) -> bool {
// Fast path: ASCII-only strings are extremely common in Azure data.
if a.is_ascii() && b.is_ascii() {
return a.eq_ignore_ascii_case(b);
}
CASE_MAPPER.fold_string(a) == CASE_MAPPER.fold_string(b)
}
/// Case-insensitive ordering using full Unicode case folding.
///
/// Equivalent to .NET `String.Compare(a, b, StringComparison.InvariantCultureIgnoreCase)`.
///
/// # Note
///
/// This compares the *folded* UTF-8 byte sequences, which gives a stable
/// total order suitable for sorting and binary search, but is not the same
/// as a full locale-aware collation. For Azure Policy's purposes (where
/// ordering is used by `greater`/`less` operators on string values) this is
/// sufficient.
pub fn cmp(a: &str, second: &str) -> Ordering {
if a.is_ascii() && second.is_ascii() {
// Compare char-by-char with ASCII folding, no allocation.
let ord = a
.bytes()
.map(|byte| byte.to_ascii_lowercase())
.cmp(second.bytes().map(|byte| byte.to_ascii_lowercase()));
return ord;
}
let fa = CASE_MAPPER.fold_string(a);
let fb = CASE_MAPPER.fold_string(second);
fa.cmp(&fb)
}
/// Case-insensitive substring test using full Unicode case folding.
///
/// Returns `true` if the folded form of `haystack` contains the folded form
/// of `needle`.
///
/// Equivalent to the `contains` / `notContains` Azure Policy operators on
/// string values.
pub fn contains(haystack: &str, needle: &str) -> bool {
if haystack.is_ascii() && needle.is_ascii() {
// ASCII fast path: avoid allocation.
// Note: This is O(n*m) but strings are typically short in policy data.
let h = haystack.as_bytes();
let n = needle.as_bytes();
if n.is_empty() {
return true;
}
if n.len() > h.len() {
return false;
}
return h.windows(n.len()).any(|w| w.eq_ignore_ascii_case(n));
}
let fh = CASE_MAPPER.fold_string(haystack);
let fn_ = CASE_MAPPER.fold_string(needle);
fh.contains(&*fn_)
}
#[cfg(test)]
mod tests {
use super::*;
// ── eq ────────────────────────────────────────────────────────────
#[test]
fn eq_ascii_same_case() {
assert!(eq("hello", "hello"));
}
#[test]
fn eq_ascii_diff_case() {
assert!(eq("Hello", "hELLO"));
}
#[test]
fn eq_sharp_s() {
// ß (U+00DF) folds to "ss"
assert!(eq("Straße", "STRASSE"));
assert!(eq("straße", "strasse"));
}
#[test]
fn eq_greek_sigma() {
// Σ, σ, ς all fold to σ
assert!(eq("ΣΕΛΑΣ", "σελας"));
assert!(eq("σελας", "σελας"));
}
#[test]
fn eq_ligature() {
// ffi (U+FB03) folds to "ffi"
assert!(eq("", "ffi"));
assert!(eq("", "FFI"));
}
#[test]
fn eq_not_equal() {
assert!(!eq("abc", "abd"));
assert!(!eq("abc", "ab"));
}
#[test]
fn eq_empty() {
assert!(eq("", ""));
assert!(!eq("", "a"));
}
// ── cmp ──────────────────────────────────────────────────────────
#[test]
fn cmp_equal() {
assert_eq!(cmp("hello", "HELLO"), Ordering::Equal);
}
#[test]
fn cmp_less() {
assert_eq!(cmp("alpha", "BETA"), Ordering::Less);
}
#[test]
fn cmp_greater() {
assert_eq!(cmp("beta", "ALPHA"), Ordering::Greater);
}
// ── contains ─────────────────────────────────────────────────────
#[test]
fn contains_ascii() {
assert!(contains("Hello World", "lo wo"));
}
#[test]
fn contains_empty_needle() {
assert!(contains("anything", ""));
}
#[test]
fn contains_sharp_s() {
assert!(contains("Die Straße ist lang", "STRASSE"));
}
#[test]
fn contains_no_match() {
assert!(!contains("hello", "xyz"));
}
// ── fold ─────────────────────────────────────────────────────────
#[test]
fn fold_ascii_lowercase_borrows() {
let result = fold("hello");
assert!(matches!(result, Cow::Borrowed(_)));
assert_eq!(&*result, "hello");
}
#[test]
fn fold_sharp_s() {
let result = fold("Straße");
assert_eq!(&*result, "strasse");
}
}

View File

@@ -0,0 +1,86 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
//! ASCII case-insensitive comparison for ARM object property keys.
//!
//! ARM resource property names are restricted to ASCII letters, digits, and
//! underscores. Key lookup in normalized ARM objects therefore only needs
//! `OrdinalIgnoreCase` semantics (fold A-Z → a-z, nothing else).
//!
//! This module wraps `eq_ignore_ascii_case` and provides an ASCII-only
//! case-insensitive ordering, both zero-allocation and branchless on modern
//! hardware.
//!
//! # Usage
//!
//! ```rust,ignore
//! use regorus::languages::azure_policy::strings::keys;
//!
//! assert!(keys::eq("Location", "location"));
//! assert_eq!(keys::cmp("Name", "name"), std::cmp::Ordering::Equal);
//! ```
use core::cmp::Ordering;
/// ASCII case-insensitive equality for ARM property keys.
///
/// This is a thin wrapper around [`str::eq_ignore_ascii_case`], made explicit
/// to document the `OrdinalIgnoreCase` semantics and keep call sites readable.
#[inline]
pub const fn eq(a: &str, b: &str) -> bool {
a.eq_ignore_ascii_case(b)
}
/// ASCII case-insensitive ordering for ARM property keys.
///
/// Compares byte-by-byte after folding each byte with
/// [`u8::to_ascii_lowercase`]. This gives a stable total order consistent
/// with [`eq`].
#[inline]
pub fn cmp(a: &str, second: &str) -> Ordering {
a.bytes()
.map(|byte| byte.to_ascii_lowercase())
.cmp(second.bytes().map(|byte| byte.to_ascii_lowercase()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn eq_same() {
assert!(eq("location", "location"));
}
#[test]
fn eq_diff_case() {
assert!(eq("Location", "LOCATION"));
assert!(eq("apiVersion", "APIversion"));
}
#[test]
fn eq_not_equal() {
assert!(!eq("name", "type"));
}
#[test]
fn eq_empty() {
assert!(eq("", ""));
assert!(!eq("", "a"));
}
#[test]
fn cmp_equal() {
assert_eq!(cmp("Location", "location"), Ordering::Equal);
}
#[test]
fn cmp_less() {
assert_eq!(cmp("apiVersion", "Name"), Ordering::Less);
}
#[test]
fn cmp_greater() {
assert_eq!(cmp("type", "Name"), Ordering::Greater);
}
}

View File

@@ -0,0 +1,28 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
//! String operations for ARM and Azure Policy.
//!
//! This module provides precise implementations of the string comparison
//! semantics used by ARM (Azure Resource Manager) and Azure Policy:
//!
//! - **[`keys`]**: ASCII case-insensitive comparison for ARM object property
//! keys (`OrdinalIgnoreCase` — only folds A-Z ↔ a-z).
//!
//! - **[`case_fold`]**: Full Unicode case folding for Azure Policy condition
//! value comparisons (`InvariantCultureIgnoreCase`). Backed by ICU4X
//! [`icu_casemap`] so that ß = SS, ffi = FFI, etc.
//!
//! # Design rationale
//!
//! ARM property key names are restricted to ASCII (letters, digits,
//! underscores), so `eq_ignore_ascii_case` is both correct and fast for key
//! lookup.
//!
//! Azure Policy condition operators (`equals`, `contains`, `like`, …) compare
//! string *values* using .NET's `InvariantCultureIgnoreCase`, which performs
//! full Unicode case folding. We use ICU4X's `CaseMapper::fold` / `fold_string`
//! for this — it is the same ICU backing that .NET 5+ uses internally.
pub mod case_fold;
pub mod keys;

View File

@@ -127,6 +127,9 @@ mod indexchecker;
mod interpreter;
pub mod languages {
#[cfg(feature = "azure_policy")]
pub mod azure_policy;
#[cfg(feature = "azure-rbac")]
pub mod azure_rbac;
@@ -601,6 +604,7 @@ pub mod coverage {
#[doc(hidden)]
pub mod unstable {
pub use crate::ast::*;
pub use crate::builtins::*;
pub use crate::lexer::*;
pub use crate::parser::*;
}

View File

@@ -0,0 +1,46 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
# Tests for azure.policy.fn.add_days
builtin: azure.policy.fn.add_days
cases:
- note: add_one_day
args: ["2024-01-15T12:00:00Z", 1]
want: "2024-01-16T12:00:00Z"
- note: add_zero_days
args: ["2024-01-15T12:00:00Z", 0]
want: "2024-01-15T12:00:00Z"
- note: subtract_one_day
args: ["2024-01-15T12:00:00Z", -1]
want: "2024-01-14T12:00:00Z"
- note: add_thirty_days
args: ["2024-01-01T00:00:00Z", 30]
want: "2024-01-31T00:00:00Z"
- note: add_days_across_month
args: ["2024-01-31T00:00:00Z", 1]
want: "2024-02-01T00:00:00Z"
- note: add_days_leap_year
args: ["2024-02-28T00:00:00Z", 1]
want: "2024-02-29T00:00:00Z"
- note: add_days_non_leap_year
args: ["2023-02-28T00:00:00Z", 1]
want: "2023-03-01T00:00:00Z"
- note: invalid_base_datetime
args: ["not-a-date", 5]
want_undefined: true
- note: non_integer_days
args: ["2024-01-15T12:00:00Z", "abc"]
want_undefined: true
- note: add_365_days
args: ["2024-01-01T00:00:00Z", 365]
want: "2024-12-31T00:00:00Z"

View File

@@ -0,0 +1,34 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
# Tests for azure.policy.fn.array
builtin: azure.policy.fn.array
cases:
- note: array_from_int
args: [42]
want: [42]
- note: array_from_string
args: ["hello"]
want: ["hello"]
- note: array_from_bool
args: [true]
want: [true]
- note: array_from_null
args: [null]
want: [null]
- note: array_from_array
args: [[1, 2, 3]]
want: [1, 2, 3]
- note: array_from_empty_array
args: [[]]
want: []
- note: array_from_object
args: [{"a": 1}]
want: [{"a": 1}]

View File

@@ -0,0 +1,38 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
# Tests for azure.policy.fn.base64 and azure.policy.fn.base64_to_string
builtin: azure.policy.fn.base64
cases:
- note: base64_encode_hello
args: ["hello"]
want: "aGVsbG8="
- note: base64_encode_empty
args: [""]
want: ""
- note: base64_encode_single_char
args: ["a"]
want: "YQ=="
- note: base64_encode_two_chars
args: ["ab"]
want: "YWI="
- note: base64_encode_three_chars
args: ["abc"]
want: "YWJj"
- note: base64_encode_json_object
args: ["{\"key\":\"value\"}"]
want: "eyJrZXkiOiJ2YWx1ZSJ9"
- note: base64_encode_numbers
args: ["12345"]
want: "MTIzNDU="
- note: base64_encode_special_chars
args: ["hello world!"]
want: "aGVsbG8gd29ybGQh"

View File

@@ -0,0 +1,39 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
# Tests for azure.policy.fn.base64_to_json
builtin: azure.policy.fn.base64_to_json
cases:
- note: base64_to_json_object
args: ["eyJrZXkiOiJ2YWx1ZSJ9"]
want:
key: "value"
- note: base64_to_json_array
args: ["WzEsMiwzXQ=="]
want: [1, 2, 3]
- note: base64_to_json_string
args: ["ImhlbGxvIg=="]
want: "hello"
- note: base64_to_json_number
args: ["NDI="]
want: 42
- note: base64_to_json_null
args: ["bnVsbA=="]
want_null: true
- note: base64_to_json_bool
args: ["dHJ1ZQ=="]
want: true
- note: base64_to_json_invalid_base64
args: ["!!!"]
want_undefined: true
- note: base64_to_json_invalid_json
args: ["bm90LWpzb24="]
want_undefined: true

View File

@@ -0,0 +1,30 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
# Tests for azure.policy.fn.base64_to_string
builtin: azure.policy.fn.base64_to_string
cases:
- note: base64_decode_hello
args: ["aGVsbG8="]
want: "hello"
- note: base64_decode_empty
args: [""]
want: ""
- note: base64_decode_single_char
args: ["YQ=="]
want: "a"
- note: base64_decode_no_padding
args: ["YWJj"]
want: "abc"
- note: base64_decode_json
args: ["eyJrZXkiOiJ2YWx1ZSJ9"]
want: "{\"key\":\"value\"}"
- note: base64_decode_invalid
args: ["!!!"]
want_undefined: true

View File

@@ -0,0 +1,70 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
# Tests for azure.policy.fn.bool
builtin: azure.policy.fn.bool
cases:
# ── From booleans ───────────────────────────────────────────────────
- note: bool_from_true
args: [true]
want: true
- note: bool_from_false
args: [false]
want: false
# ── From strings ────────────────────────────────────────────────────
- note: bool_from_string_true
args: ["true"]
want: true
- note: bool_from_string_false
args: ["false"]
want: false
- note: bool_from_string_TRUE
args: ["TRUE"]
want: true
- note: bool_from_string_False
args: ["False"]
want: false
- note: bool_from_string_1
args: ["1"]
want: true
- note: bool_from_string_0
args: ["0"]
want: false
- note: bool_from_string_invalid
args: ["yes"]
want_undefined: true
- note: bool_from_string_empty
args: [""]
want_undefined: true
# ── From numbers ────────────────────────────────────────────────────
- note: bool_from_number_1
args: [1]
want: true
- note: bool_from_number_0
args: [0]
want: false
- note: bool_from_number_negative
args: [-1]
want: true
- note: bool_from_number_42
args: [42]
want: true
# ── Other types ─────────────────────────────────────────────────────
- note: bool_from_null
args: [null]
want_undefined: true

View File

@@ -0,0 +1,42 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
# Tests for azure.policy.fn.coalesce
builtin: azure.policy.fn.coalesce
cases:
- note: coalesce_first_non_null
args: [null, null, "hello"]
want: "hello"
- note: coalesce_first_is_value
args: ["first", "second"]
want: "first"
- note: coalesce_all_null
args: [null, null, null]
want_null: true
- note: coalesce_number
args: [null, 42]
want: 42
- note: coalesce_false_is_not_null
args: [null, false, "hello"]
want: false
- note: coalesce_zero_is_not_null
args: [null, 0, "hello"]
want: 0
- note: coalesce_empty_string_is_not_null
args: [null, "", "hello"]
want: ""
- note: coalesce_single_value
args: [42]
want: 42
- note: coalesce_single_null
args: [null]
want_null: true

View File

@@ -0,0 +1,34 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
# Tests for azure.policy.fn.create_object
builtin: azure.policy.fn.create_object
cases:
- note: create_object_single_pair
args: ["name", "Alice"]
want: {"name": "Alice"}
- note: create_object_two_pairs
args: ["name", "Alice", "age", 30]
want: {"age": 30, "name": "Alice"}
- note: create_object_mixed_types
args: ["flag", true, "count", 5, "label", "test"]
want: {"count": 5, "flag": true, "label": "test"}
- note: create_object_empty
args: []
want: {}
- note: create_object_nested_value
args: ["data", {"inner": "value"}]
want: {"data": {"inner": "value"}}
- note: create_object_odd_args_error
args: ["key1", "val1", "key2"]
want_error: "expected an even number of arguments"
- note: create_object_numeric_key
args: [1, "one", 2, "two"]
want: {1: "one", 2: "two"}

View File

@@ -0,0 +1,18 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
# Tests for azure.policy.fn.data_uri and azure.policy.fn.data_uri_to_string
builtin: azure.policy.fn.data_uri
cases:
- note: data_uri_hello
args: ["hello"]
want: "data:text/plain;charset=utf8;base64,aGVsbG8="
- note: data_uri_empty
args: [""]
want: "data:text/plain;charset=utf8;base64,"
- note: data_uri_json
args: ["{\"key\":\"value\"}"]
want: "data:text/plain;charset=utf8;base64,eyJrZXkiOiJ2YWx1ZSJ9"

View File

@@ -0,0 +1,18 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
# Tests for azure.policy.fn.data_uri_to_string
builtin: azure.policy.fn.data_uri_to_string
cases:
- note: data_uri_to_string_hello
args: ["data:text/plain;charset=utf8;base64,aGVsbG8="]
want: "hello"
- note: data_uri_to_string_json
args: ["data:text/plain;charset=utf8;base64,eyJrZXkiOiJ2YWx1ZSJ9"]
want: "{\"key\":\"value\"}"
- note: data_uri_to_string_invalid
args: ["not-a-data-uri"]
want_undefined: true

View File

@@ -0,0 +1,161 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
# Tests for azure.policy.fn.date_time_add
builtin: azure.policy.fn.date_time_add
cases:
- note: add_one_day
args: ["2024-01-15T12:00:00Z", "P1D"]
want: "2024-01-16T12:00:00Z"
- note: add_one_hour
args: ["2024-01-15T12:00:00Z", "PT1H"]
want: "2024-01-15T13:00:00Z"
- note: add_thirty_minutes
args: ["2024-01-15T12:00:00Z", "PT30M"]
want: "2024-01-15T12:30:00Z"
- note: add_negative_one_day
args: ["2024-01-15T12:00:00Z", "-P1D"]
want: "2024-01-14T12:00:00Z"
- note: add_complex_duration
args: ["2024-01-15T12:00:00Z", "P1DT2H30M"]
want: "2024-01-16T14:30:00Z"
- note: add_one_year_approx
args: ["2024-01-01T00:00:00Z", "P1Y"]
want: "2024-12-31T00:00:00Z"
- note: add_zero_duration
args: ["2024-06-15T10:30:00Z", "PT0S"]
want: "2024-06-15T10:30:00Z"
- note: invalid_base_datetime
args: ["not-a-date", "P1D"]
want_undefined: true
- note: invalid_duration
args: ["2024-01-15T12:00:00Z", "invalid"]
want_undefined: true
- note: datetime_without_timezone
args: ["2024-01-15T12:00:00", "P1D"]
want: "2024-01-16T12:00:00"
- note: add_one_week
args: ["2024-01-15T00:00:00Z", "P1W"]
want: "2024-01-22T00:00:00Z"
- note: space_separated_datetime
args: ["2020-04-07 14:55:59", "P3D"]
want: "2020-04-10 14:55:59"
- note: space_separated_datetime_with_z
args: ["2020-04-07 14:55:59Z", "P3D"]
want: "2020-04-10 14: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"
- note: output_format_date_only
args: ["2020-04-07T14:55:59Z", "P3Y2M", "yyyy-MM-dd"]
want: "2023-06-06"
- note: output_format_custom
args: ["2024-01-15T12:00:00Z", "PT1H", "yyyy-MM-dd HH:mm:ss"]
want: "2024-01-15 13:00:00"
- note: output_format_with_k_utc
args: ["2024-01-15T12:00:00Z", "P0D", "yyyy-MM-ddTHH:mm:ssK"]
want: "2024-01-15T12:00:00Z"
# Standard .NET format specifiers
- note: output_standard_format_d
args: ["2024-01-15T12:30:00Z", "P1D", "d"]
want: "01/16/2024"
- note: output_standard_format_G
args: ["2024-01-15T12:30:00Z", "PT1H", "G"]
want: "01/15/2024 13:30:00"
- note: output_standard_format_o
args: ["2024-01-15T12:00:00Z", "P0D", "o"]
want: "2024-01-15T12:00:00.0000000Z"
- note: output_standard_format_o_with_nanos
args: ["2024-01-15T12:00:00.123456700Z", "P0D", "o"]
want: "2024-01-15T12:00:00.1234567Z"
- note: output_standard_format_u
args: ["2024-01-15T12:00:00Z", "P0D", "u"]
want: "2024-01-15 12:00:00Z"
- note: output_standard_format_u_with_offset
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)
- note: roundtrip_iso_no_tz_with_frac
args: ["2024-01-15T12:00:00.123", "P0D"]
want: "2024-01-15T12:00:00.123"
- note: roundtrip_iso_no_tz_with_frac_add
args: ["2024-01-15T12:00:00.500", "P1D"]
want: "2024-01-16T12:00:00.500"
- note: roundtrip_space_no_tz_with_frac
args: ["2024-01-15 12:00:00.456", "P0D"]
want: "2024-01-15 12:00:00.456"
- note: roundtrip_space_z_with_frac
args: ["2024-01-15 12:00:00.789Z", "P0D"]
want: "2024-01-15 12: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"
- note: roundtrip_no_frac_stays_clean
args: ["2024-01-15T12:00:00", "P0D"]
want: "2024-01-15T12:00:00"
- note: roundtrip_rfc3339_utc_with_frac
args: ["2024-01-15T12:00:00.123Z", "P0D"]
want: "2024-01-15T12:00:00.123Z"
- note: roundtrip_rfc3339_offset_with_frac
args: ["2024-01-15T12:00:00.456+05:30", "P0D"]
want: "2024-01-15T12:00:00.456+05:30"
- note: roundtrip_rfc3339_utc_no_frac_stays_clean
args: ["2024-01-15T12:00:00Z", "P0D"]
want: "2024-01-15T12:00:00Z"
- note: roundtrip_rfc3339_explicit_zero_offset
args: ["2024-01-15T12:00:00+00:00", "P0D"]
want: "2024-01-15T12:00:00+00:00"
- 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"
# Unknown single-char format specifier is a real error
- note: unknown_format_specifier_q
args: ["2024-01-15T12:00:00Z", "P0D", "q"]
want_error: "unrecognised standard format specifier 'q'"
- note: output_standard_format_U
args: ["2024-01-15T12:00:00+05:30", "P0D", "U"]
want: "Monday, 15 January 2024 06:30:00"
- note: output_standard_format_s
args: ["2024-01-15T12:00:00Z", "P0D", "s"]
want: "2024-01-15T12:00:00"
- note: output_standard_format_R
args: ["2024-01-15T12:00:00+05:30", "P0D", "R"]
want: "Mon, 15 Jan 2024 06:30:00 GMT"

View File

@@ -0,0 +1,30 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
# Tests for azure.policy.fn.date_time_from_epoch
builtin: azure.policy.fn.date_time_from_epoch
cases:
- note: unix_epoch_zero
args: [0]
want: "1970-01-01T00:00:00Z"
- note: specific_timestamp
args: [1705320000]
want: "2024-01-15T12:00:00Z"
- note: negative_timestamp
args: [-86400]
want: "1969-12-31T00:00:00Z"
- note: max_reasonable_timestamp
args: [4102444800]
want: "2100-01-01T00:00:00Z"
- note: one_second_after_epoch
args: [1]
want: "1970-01-01T00:00:01Z"
- note: non_number_arg
args: ["hello"]
want_undefined: true

View File

@@ -0,0 +1,46 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
# Tests for azure.policy.fn.date_time_to_epoch
builtin: azure.policy.fn.date_time_to_epoch
cases:
- note: epoch_zero
args: ["1970-01-01T00:00:00Z"]
want: 0
- note: specific_datetime
args: ["2024-01-15T12:00:00Z"]
want: 1705320000
- note: datetime_with_offset
args: ["2024-01-15T13:00:00+01:00"]
want: 1705320000
- note: before_epoch
args: ["1969-12-31T00:00:00Z"]
want: -86400
- note: invalid_datetime
args: ["not-a-date"]
want_undefined: true
- note: non_string_arg
args: [12345]
want_undefined: true
- note: datetime_without_tz
args: ["2024-01-15T12:00:00"]
want: 1705320000
- note: space_separated_datetime
args: ["2024-01-15 12:00:00"]
want: 1705320000
- note: space_separated_datetime_with_z
args: ["2024-01-15 12:00:00Z"]
want: 1705320000
- note: space_separated_datetime_with_offset
args: ["2024-01-15 13:00:00+01:00"]
want: 1705320000

View File

@@ -0,0 +1,55 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
# Tests for azure.policy.fn.empty
builtin: azure.policy.fn.empty
cases:
# ── Strings ─────────────────────────────────────────────────────────
- note: empty_string_true
args: [""]
want: true
- note: empty_string_false
args: ["hello"]
want: false
- note: empty_string_space
args: [" "]
want: false
# ── Arrays ──────────────────────────────────────────────────────────
- note: empty_array_true
args: [[]]
want: true
- note: empty_array_false
args: [[1, 2]]
want: false
# ── Objects ─────────────────────────────────────────────────────────
- note: empty_object_true
args: [{}]
want: true
- note: empty_object_false
args: [{"a": 1}]
want: false
# ── Null / special values ───────────────────────────────────────────
- note: empty_null
args: [null]
want: true
# ── Numbers and bools (not empty) ──────────────────────────────────
- note: empty_number
args: [0]
want: false
- note: empty_bool_false
args: [false]
want: false
- note: empty_bool_true
args: [true]
want: false

View File

@@ -0,0 +1,38 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
# Tests for azure.policy.fn.ends_with
builtin: azure.policy.fn.ends_with
cases:
- note: ends_with_true
args: ["hello world", "world"]
want: true
- note: ends_with_false
args: ["hello world", "hello"]
want: false
- note: ends_with_case_insensitive
args: ["Hello World", "WORLD"]
want: true
- note: ends_with_empty_needle
args: ["hello", ""]
want: true
- note: ends_with_empty_haystack
args: ["", "hello"]
want: false
- note: ends_with_both_empty
args: ["", ""]
want: true
- note: ends_with_exact_match
args: ["hello", "hello"]
want: true
- note: ends_with_longer_needle
args: ["hi", "hello"]
want: false

View File

@@ -0,0 +1,44 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
# Tests for azure.policy.fn.first
builtin: azure.policy.fn.first
cases:
# ── Arrays ──────────────────────────────────────────────────────────
- note: first_array_single
args: [[42]]
want: 42
- note: first_array_multiple
args: [[10, 20, 30]]
want: 10
- note: first_array_strings
args: [["alpha", "beta", "gamma"]]
want: "alpha"
- note: first_array_mixed
args: [[true, 1, "x"]]
want: true
- note: first_array_empty
args: [[]]
want_null: true
# ── Strings ─────────────────────────────────────────────────────────
- note: first_string
args: ["hello"]
want: "h"
- note: first_string_single_char
args: ["x"]
want: "x"
- note: first_string_empty
args: [""]
want: ""
- note: first_string_unicode
args: ["über"]
want: "ü"

View File

@@ -0,0 +1,38 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
# Tests for azure.policy.fn.float
builtin: azure.policy.fn.float
cases:
- note: float_from_int
args: [42]
want: 42.0
- note: float_from_float
args: [3.14]
want: 3.14
- note: float_from_zero
args: [0]
want: 0.0
- note: float_from_negative
args: [-5]
want: -5.0
- note: float_from_string
args: ["3.14"]
want: 3.14
- note: float_from_string_int
args: ["42"]
want: 42.0
- note: float_from_string_invalid
args: ["hello"]
want_undefined: true
- note: float_from_null
args: [null]
want_undefined: true

View File

@@ -0,0 +1,131 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
# Tests for azure.policy.fn.format
builtin: azure.policy.fn.format
cases:
- note: format_single_placeholder
args: ["Hello {0}!", "world"]
want: "Hello world!"
- note: format_multiple_placeholders
args: ["{0} is {1}", "sky", "blue"]
want: "sky is blue"
- note: format_repeated_placeholder
args: ["{0} and {0}", "hello"]
want: "hello and hello"
- note: format_number_arg
args: ["Count: {0}", 42]
want: "Count: 42"
- note: format_bool_arg
args: ["Value: {0}", true]
want: "Value: True"
- note: format_no_placeholders
args: ["no placeholders"]
want: "no placeholders"
- note: format_three_args
args: ["{0}-{1}-{2}", "a", "b", "c"]
want: "a-b-c"
- note: format_missing_placeholder_error
args: ["{0} and {1}", "only_first"]
want_error: "placeholder {1} references argument index 1"
- note: format_empty_template
args: [""]
want: ""
- note: format_escaped_braces
args: ["{{literal braces}}"]
want: "{literal braces}"
- note: format_mixed_escaped_and_placeholder
args: ["{{{0}}}", "value"]
want: "{value}"
- note: format_alignment_right
args: ["{0,10}", "hi"]
want: " hi"
- note: format_alignment_left
args: ["{0,-10}", "hi"]
want: "hi "
- note: format_numeric_fixed_point
args: ["{0:F2}", 3.14159]
want: "3.14"
- note: format_numeric_with_thousands
args: ["{0:N0}", 1234567]
want: "1,234,567"
- note: format_float_with_thousands
args: ["{0:N2}", 1234.5678]
want: "1,234.57"
- note: format_hex_upper
args: ["{0:X}", 255]
want: "FF"
- note: format_hex_lower_padded
args: ["{0:x4}", 255]
want: "00ff"
- note: format_decimal_padded
args: ["{0:D5}", 42]
want: "00042"
- note: format_percent
args: ["{0:P1}", 0.1234]
want: "12.3 %"
- note: format_unmatched_closing_brace
args: ["hello } world"]
want_error: "unmatched closing brace"
- note: format_invalid_placeholder_no_index
args: ["{abc}"]
want_error: "invalid placeholder"
- note: format_unmatched_opening_brace
args: ["{0", "value"]
want_error: "unmatched opening brace"
- note: format_alignment_non_ascii
args: ["{0,6}", "café"]
want: " café"
# Invalid alignment clauses (must be rejected)
- note: format_alignment_empty
args: ["{0,}", "hi"]
want_error: "empty alignment value"
- note: format_alignment_non_numeric
args: ["{0,abc}", "hi"]
want_error: "invalid alignment"
- note: format_alignment_trailing_junk
args: ["{0, 1x}", "hi"]
want_error: "invalid alignment"
- note: format_unknown_numeric_specifier_error
args: ["{0:Z}", 42]
want_error: "invalid numeric format specifier"
- note: format_alignment_width_exceeds_max
args: ["{0,1000000000}", "hi"]
want_error: "exceeds maximum allowed"
- note: format_fixed_point_higher_precision
args: ["{0:F3}", 1.23456]
want: "1.235"
- note: format_negative_index_error
args: ["{-1}", "value"]
want_error: "invalid placeholder"

View File

@@ -0,0 +1,46 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
# Tests for azure.policy.get_parameter
builtin: azure.policy.get_parameter
cases:
- note: param_from_user_params
args:
- allowedLocations: ["eastus", "westus"]
- allowedLocations: ["centralus"]
- "allowedLocations"
want: ["eastus", "westus"]
- note: param_falls_back_to_defaults
args:
- {}
- effect: "Deny"
- "effect"
want: "Deny"
- note: param_not_in_either
args:
- {}
- {}
- "missing"
want_undefined: true
- note: param_override_default
args:
- maxAge: 90
- maxAge: 365
- "maxAge"
want: 90
- note: param_with_nested_value
args:
- config:
enabled: true
threshold: 50
- config:
enabled: false
- "config"
want:
enabled: true
threshold: 50

View File

@@ -0,0 +1,38 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
# Tests for azure.policy.if (ARM template if() function)
builtin: azure.policy.if
cases:
- note: condition_true_returns_when_true
args: [true, "yes", "no"]
want: "yes"
- note: condition_false_returns_when_false
args: [false, "yes", "no"]
want: "no"
- note: true_with_numbers
args: [true, 1, 0]
want: 1
- note: false_with_numbers
args: [false, 1, 0]
want: 0
- note: true_with_arrays
args: [true, [1, 2], [3, 4]]
want: [1, 2]
- note: false_with_arrays
args: [false, [1, 2], [3, 4]]
want: [3, 4]
- note: true_with_null_branches
args: [true, null, "fallback"]
want_null: true
- note: false_with_null_branch
args: [false, "value", null]
want_null: true

View File

@@ -0,0 +1,42 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
# Tests for azure.policy.fn.index_from_end
builtin: azure.policy.fn.index_from_end
cases:
- note: last_element
args: [["a", "b", "c", "d"], 1]
want: "d"
- note: second_from_end
args: [["a", "b", "c", "d"], 2]
want: "c"
- note: third_from_end
args: [["a", "b", "c", "d"], 3]
want: "b"
- note: first_element_via_end
args: [["a", "b", "c", "d"], 4]
want: "a"
- note: single_element_array
args: [["only"], 1]
want: "only"
- note: index_zero_error
args: [["a", "b"], 0]
want_error: "out of bounds"
- note: index_exceeds_length
args: [["a", "b"], 3]
want_error: "out of bounds"
- note: non_array_arg
args: ["not_an_array", 1]
want_undefined: true
- note: numeric_array
args: [[10, 20, 30], 1]
want: 30

View File

@@ -0,0 +1,54 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
# Tests for azure.policy.fn.index_of
builtin: azure.policy.fn.index_of
cases:
- note: index_of_found
args: ["hello world", "world"]
want: 6
- note: index_of_beginning
args: ["hello", "hello"]
want: 0
- note: index_of_not_found
args: ["hello", "xyz"]
want: -1
- note: index_of_empty_needle
args: ["hello", ""]
want: 0
- note: index_of_empty_haystack
args: ["", "hello"]
want: -1
- note: index_of_both_empty
args: ["", ""]
want: 0
- note: index_of_single_char
args: ["abcdef", "d"]
want: 3
- note: index_of_first_occurrence
args: ["abcabc", "bc"]
want: 1
- note: index_of_case_insensitive
args: ["Hello", "hello"]
want: 0
- note: index_of_case_insensitive_mixed
args: ["Hello World", "WORLD"]
want: 6
- note: index_of_unicode_char_index
args: ["café latte", "latte"]
want: 5
- note: index_of_unicode_case_fold
args: ["Straße", "STRASSE"]
want: 0

View File

@@ -0,0 +1,57 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
# Tests for azure.policy.fn.int
builtin: azure.policy.fn.int
cases:
# ── From numbers ────────────────────────────────────────────────────
- note: int_from_integer
args: [42]
want: 42
- note: int_from_negative
args: [-7]
want: -7
- note: int_from_zero
args: [0]
want: 0
- note: int_from_float_truncates
args: [3.9]
want: 3
- note: int_from_negative_float
args: [-2.7]
want: -2
# ── From strings ────────────────────────────────────────────────────
- note: int_from_string_integer
args: ["42"]
want: 42
- note: int_from_string_negative
args: ["-10"]
want: -10
- note: int_from_string_float
args: ["3.14"]
want: 3
- note: int_from_string_invalid
args: ["hello"]
want_undefined: true
- note: int_from_string_empty
args: [""]
want_undefined: true
# ── Other types ────────────────────────────────────────────────────
- note: int_from_bool
args: [true]
want_undefined: true
- note: int_from_null
args: [null]
want_undefined: true

View File

@@ -0,0 +1,38 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
# Tests for azure.policy.fn.int_div
builtin: azure.policy.fn.int_div
cases:
- note: int_div_basic
args: [10, 3]
want: 3
- note: int_div_exact
args: [10, 5]
want: 2
- note: int_div_one
args: [7, 1]
want: 7
- note: int_div_negative
args: [-10, 3]
want: -3
- note: int_div_both_negative
args: [-10, -3]
want: 3
- note: int_div_zero_numerator
args: [0, 5]
want: 0
- note: int_div_by_zero
args: [10, 0]
want_undefined: true
- note: int_div_large
args: [1000000, 7]
want: 142857

View File

@@ -0,0 +1,38 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
# Tests for azure.policy.fn.int_mod
builtin: azure.policy.fn.int_mod
cases:
- note: int_mod_basic
args: [10, 3]
want: 1
- note: int_mod_exact
args: [10, 5]
want: 0
- note: int_mod_one
args: [7, 1]
want: 0
- note: int_mod_negative
args: [-10, 3]
want: -1
- note: int_mod_both_negative
args: [-10, -3]
want: -1
- note: int_mod_zero_numerator
args: [0, 5]
want: 0
- note: int_mod_by_zero
args: [10, 0]
want_undefined: true
- note: int_mod_large
args: [1000000, 7]
want: 1

View File

@@ -0,0 +1,58 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
# Tests for azure.policy.fn.intersection
builtin: azure.policy.fn.intersection
cases:
# ── Array intersection ──────────────────────────────────────────────
- note: intersection_arrays_overlap
args: [[1, 2, 3], [2, 3, 4]]
want: [2, 3]
- note: intersection_arrays_no_overlap
args: [[1, 2], [3, 4]]
want: []
- note: intersection_arrays_identical
args: [[1, 2, 3], [1, 2, 3]]
want: [1, 2, 3]
- note: intersection_arrays_empty_first
args: [[], [1, 2]]
want: []
- note: intersection_arrays_empty_second
args: [[1, 2], []]
want: []
- note: intersection_arrays_three
args: [[1, 2, 3, 4], [2, 3, 4, 5], [3, 4, 5, 6]]
want: [3, 4]
- note: intersection_arrays_strings
args: [["a", "b", "c"], ["b", "c", "d"]]
want: ["b", "c"]
# ── Object intersection ─────────────────────────────────────────────
# Azure semantics: a key is kept only when it exists in ALL objects
# AND the value is the same across all of them.
- note: intersection_objects_same_values
args: [{"a": 1, "b": 2, "c": 3}, {"b": 2, "c": 3, "d": 4}]
want: {"b": 2, "c": 3}
- note: intersection_objects_different_values
args: [{"a": 1, "b": 2, "c": 3}, {"b": 20, "c": 30, "d": 40}]
want: {}
- note: intersection_objects_no_overlap
args: [{"a": 1}, {"b": 2}]
want: {}
- note: intersection_objects_identical
args: [{"x": 1}, {"x": 1}]
want: {"x": 1}
- note: intersection_objects_partial_value_match
args: [{"a": 1, "b": 2}, {"a": 1, "b": 99}]
want: {"a": 1}

View File

@@ -0,0 +1,65 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
# Tests for azure.policy.fn.ip_range_contains
builtin: azure.policy.fn.ip_range_contains
cases:
# ── IPv4 ────────────────────────────────────────────────────────────
- note: ipv4_contains_ip_true
args: ["10.0.0.0/8", "10.1.2.3"]
want: true
- note: ipv4_contains_ip_false
args: ["10.0.0.0/8", "192.168.1.1"]
want: false
- note: ipv4_contains_subnet_true
args: ["10.0.0.0/8", "10.0.0.0/16"]
want: true
- note: ipv4_contains_subnet_false
args: ["10.0.0.0/16", "10.0.0.0/8"]
want: false
- note: ipv4_exact_match
args: ["192.168.1.0/24", "192.168.1.100"]
want: true
- note: ipv4_edge_network_address
args: ["192.168.1.0/24", "192.168.1.0"]
want: true
- note: ipv4_edge_broadcast
args: ["192.168.1.0/24", "192.168.1.255"]
want: true
- note: ipv4_just_outside
args: ["192.168.1.0/24", "192.168.2.0"]
want: false
- note: ipv4_slash_32
args: ["10.0.0.1/32", "10.0.0.1"]
want: true
- note: ipv4_slash_32_miss
args: ["10.0.0.1/32", "10.0.0.2"]
want: false
# ── IPv6 ────────────────────────────────────────────────────────────
- note: ipv6_contains_ip_true
args: ["fd00::/8", "fd12:3456::1"]
want: true
- note: ipv6_contains_ip_false
args: ["fd00::/8", "2001:db8::1"]
want: false
# ── Invalid inputs ──────────────────────────────────────────────────
- note: ip_invalid_range
args: ["not-a-cidr", "10.0.0.1"]
want: false
- note: ip_invalid_target
args: ["10.0.0.0/8", "not-an-ip"]
want: false

View File

@@ -0,0 +1,40 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
# Tests for azure.policy.fn.items
builtin: azure.policy.fn.items
cases:
- note: items_simple_object
args:
- name: "test"
value: 42
want:
- key: "name"
value: "test"
- key: "value"
value: 42
- note: items_single_key
args:
- a: 1
want:
- key: "a"
value: 1
- note: items_nested_value
args:
- x:
inner: true
want:
- key: "x"
value:
inner: true
- note: items_non_object
args: ["not_an_object"]
want_undefined: true
- note: items_number_arg
args: [42]
want_undefined: true

View File

@@ -0,0 +1,42 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
# Tests for azure.policy.fn.join
builtin: azure.policy.fn.join
cases:
- note: join_strings_comma
args: [["a", "b", "c"], ","]
want: "a,b,c"
- note: join_strings_space
args: [["hello", "world"], " "]
want: "hello world"
- note: join_single_element
args: [["only"], ","]
want: "only"
- note: join_empty_array
args: [[], ","]
want: ""
- note: join_empty_delimiter
args: [["a", "b", "c"], ""]
want: "abc"
- note: join_with_numbers
args: [[1, 2, 3], "-"]
want: "1-2-3"
- note: join_with_booleans
args: [[true, false], "|"]
want: "true|false"
- note: join_with_null
args: [["a", null, "b"], ","]
want: "a,null,b"
- note: join_multichar_delimiter
args: [["x", "y", "z"], " :: "]
want: "x :: y :: z"

View File

@@ -0,0 +1,51 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
# Tests for azure.policy.fn.json
builtin: azure.policy.fn.json
cases:
- note: parse_object
args: ['{"name":"test","value":42}']
want:
name: test
value: 42
- note: parse_array
args: ['[1,2,3]']
want: [1, 2, 3]
- note: parse_string
args: ['"hello"']
want: "hello"
- note: parse_number
args: ["42"]
want: 42
- note: parse_boolean_true
args: ["true"]
want: true
- note: parse_boolean_false
args: ["false"]
want: false
- note: parse_null
args: ["null"]
want_null: true
- note: parse_nested_object
args: ['{"a":{"b":{"c":1}}}']
want:
a:
b:
c: 1
- note: invalid_json
args: ["{not valid json}"]
want_error: "json()"
- note: non_string_arg
args: [42]
want_undefined: true

View File

@@ -0,0 +1,40 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
# Tests for azure.policy.fn.last
builtin: azure.policy.fn.last
cases:
# ── Arrays ──────────────────────────────────────────────────────────
- note: last_array_single
args: [[42]]
want: 42
- note: last_array_multiple
args: [[10, 20, 30]]
want: 30
- note: last_array_strings
args: [["alpha", "beta", "gamma"]]
want: "gamma"
- note: last_array_empty
args: [[]]
want_null: true
# ── Strings ─────────────────────────────────────────────────────────
- note: last_string
args: ["hello"]
want: "o"
- note: last_string_single_char
args: ["x"]
want: "x"
- note: last_string_empty
args: [""]
want: ""
- note: last_string_unicode
args: ["café"]
want: "é"

View File

@@ -0,0 +1,34 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
# Tests for azure.policy.fn.last_index_of
builtin: azure.policy.fn.last_index_of
cases:
- note: last_index_of_found
args: ["hello world hello", "hello"]
want: 12
- note: last_index_of_single_occurrence
args: ["hello world", "world"]
want: 6
- note: last_index_of_not_found
args: ["hello", "xyz"]
want: -1
- note: last_index_of_empty_needle
args: ["hello", ""]
want: 5
- note: last_index_of_repeated
args: ["abcabcabc", "abc"]
want: 6
- note: last_index_of_single_char
args: ["abcabc", "c"]
want: 5
- note: last_index_of_case_insensitive
args: ["Hello HELLO hello", "hello"]
want: 12

View File

@@ -0,0 +1,38 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
# Tests for azure.policy.logic_all (ARM template and() function)
builtin: azure.policy.logic_all
cases:
- note: all_true
args: [true, true, true]
want: true
- note: one_false
args: [true, false, true]
want: false
- note: all_false
args: [false, false, false]
want: false
- note: single_true
args: [true]
want: true
- note: single_false
args: [false]
want: false
- note: two_args_both_true
args: [true, true]
want: true
- note: two_args_one_false
args: [true, false]
want: false
- note: empty_args_vacuously_true
args: []
want: true

View File

@@ -0,0 +1,34 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
# Tests for azure.policy.logic_any (ARM template or() function)
builtin: azure.policy.logic_any
cases:
- note: all_true
args: [true, true, true]
want: true
- note: one_true
args: [false, true, false]
want: true
- note: all_false
args: [false, false, false]
want: false
- note: single_true
args: [true]
want: true
- note: single_false
args: [false]
want: false
- note: two_args_one_true
args: [false, true]
want: true
- note: empty_args_vacuously_false
args: []
want: false

View File

@@ -0,0 +1,42 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
# Tests for azure.policy.fn.max
builtin: azure.policy.fn.max
cases:
- note: max_two_args
args: [3, 1]
want: 3
- note: max_three_args
args: [5, 2, 8]
want: 8
- note: max_equal_args
args: [3, 3, 3]
want: 3
- note: max_negative
args: [-5, 0, 5]
want: 5
- note: max_single_arg
args: [42]
want: 42
- note: max_array
args: [[3, 1, 4, 1, 5]]
want: 5
- note: max_array_single
args: [[99]]
want: 99
- note: max_array_negatives
args: [[-10, -20, -5]]
want: -5
- note: max_array_empty
args: [[]]
want_undefined: true

View File

@@ -0,0 +1,44 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
# Tests for azure.policy.fn.min
builtin: azure.policy.fn.min
cases:
# ── Multiple arguments ──────────────────────────────────────────────
- note: min_two_args
args: [3, 1]
want: 1
- note: min_three_args
args: [5, 2, 8]
want: 2
- note: min_equal_args
args: [3, 3, 3]
want: 3
- note: min_negative
args: [-5, 0, 5]
want: -5
- note: min_single_arg
args: [42]
want: 42
# ── Array argument ──────────────────────────────────────────────────
- note: min_array
args: [[3, 1, 4, 1, 5]]
want: 1
- note: min_array_single
args: [[99]]
want: 99
- note: min_array_negatives
args: [[-10, -20, -5]]
want: -20
- note: min_array_empty
args: [[]]
want_undefined: true

View File

@@ -0,0 +1,38 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
# Tests for azure.policy.fn.pad_left
builtin: azure.policy.fn.pad_left
cases:
- note: pad_left_basic
args: ["42", 5, "0"]
want: "00042"
- note: pad_left_no_padding_needed
args: ["hello", 3, "x"]
want: "hello"
- note: pad_left_exact_width
args: ["abc", 3, "x"]
want: "abc"
- note: pad_left_default_space
args: ["42", 5, " "]
want: " 42"
- note: pad_left_single_char
args: ["x", 5, "-"]
want: "----x"
- note: pad_left_empty_string
args: ["", 3, "0"]
want: "000"
- note: pad_left_width_1
args: ["abc", 1, "0"]
want: "abc"
- note: pad_left_width_0
args: ["abc", 0, "0"]
want: "abc"

View File

@@ -0,0 +1,38 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
# Tests for azure.policy.fn.range
builtin: azure.policy.fn.range
cases:
- note: range_basic
args: [0, 5]
want: [0, 1, 2, 3, 4]
- note: range_from_offset
args: [3, 4]
want: [3, 4, 5, 6]
- note: range_single
args: [0, 1]
want: [0]
- note: range_zero_count
args: [5, 0]
want: []
- note: range_negative_start
args: [-2, 4]
want: [-2, -1, 0, 1]
- note: range_large_start
args: [100, 3]
want: [100, 101, 102]
- note: range_count_exceeds_10000
args: [0, 10001]
want_error: "exceeds maximum of 10000"
- note: range_sum_exceeds_i32_max
args: [2147483640, 10]
want_error: "exceeds maximum of 2147483647"

View File

@@ -0,0 +1,49 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
# Tests for azure.policy.resolve_field
builtin: azure.policy.resolve_field
cases:
- note: simple_field
args:
- name: "myResource"
type: "Microsoft.Compute/virtualMachines"
- "name"
want: "myResource"
- note: nested_field_with_dot_path
args:
- properties:
securityRules:
enabled: true
- "properties.securityRules.enabled"
want: true
- note: missing_field
args:
- name: "myResource"
- "nonExistent"
want_undefined: true
- note: deeply_nested
args:
- a:
b:
c:
d: "deep"
- "a.b.c.d"
want: "deep"
- note: top_level_type
args:
- type: "Microsoft.Storage/storageAccounts"
kind: "StorageV2"
- "type"
want: "Microsoft.Storage/storageAccounts"
- note: non_string_path
args:
- name: "test"
- 42
want_undefined: true

View File

@@ -0,0 +1,48 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
# Tests for azure.policy.fn.skip
builtin: azure.policy.fn.skip
cases:
# ── Arrays ──────────────────────────────────────────────────────────
- note: skip_array_basic
args: [[1, 2, 3, 4, 5], 2]
want: [3, 4, 5]
- note: skip_array_zero
args: [[1, 2, 3], 0]
want: [1, 2, 3]
- note: skip_array_all
args: [[1, 2, 3], 3]
want: []
- note: skip_array_more_than_length
args: [[1, 2], 5]
want: []
- note: skip_array_one
args: [[10, 20, 30], 1]
want: [20, 30]
- note: skip_array_empty
args: [[], 3]
want: []
# ── Strings ─────────────────────────────────────────────────────────
- note: skip_string_basic
args: ["hello world", 6]
want: "world"
- note: skip_string_zero
args: ["hello", 0]
want: "hello"
- note: skip_string_all
args: ["hello", 5]
want: ""
- note: skip_string_more_than_length
args: ["hi", 10]
want: ""

View File

@@ -0,0 +1,61 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
# Tests for azure.policy.fn.split
builtin: azure.policy.fn.split
cases:
# ── Basic splitting ─────────────────────────────────────────────────
- note: split_simple_comma
args: ["hello,world", ","]
want: ["hello", "world"]
- note: split_multiple_segments
args: ["a.b.c.d", "."]
want: ["a", "b", "c", "d"]
- note: split_no_match
args: ["hello", ","]
want: ["hello"]
- note: split_empty_string
args: ["", ","]
want: [""]
- note: split_delimiter_at_edges
args: [",hello,", ","]
want: ["", "hello", ""]
- note: split_consecutive_delimiters
args: ["a,,b", ","]
want: ["a", "", "b"]
- note: split_multi_char_delimiter
args: ["a::b::c", "::"]
want: ["a", "b", "c"]
- note: split_single_char_string
args: ["x", "x"]
want: ["", ""]
# ── Array of delimiters ────────────────────────────────────────────
- note: split_array_delimiters
args: ["a.b/c", [".", "/"]]
want: ["a", "b", "c"]
- note: split_array_single_delimiter
args: ["a-b-c", ["-"]]
want: ["a", "b", "c"]
# ── Edge cases ──────────────────────────────────────────────────────
- note: split_unicode
args: ["café☕bar", "☕"]
want: ["café", "bar"]
- note: split_entire_string_is_delimiter
args: ["abc", "abc"]
want: ["", ""]
- note: split_empty_delimiter
args: ["abc", ""]
want: ["abc"]

View File

@@ -0,0 +1,38 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
# Tests for azure.policy.fn.starts_with
builtin: azure.policy.fn.starts_with
cases:
- note: starts_with_true
args: ["hello world", "hello"]
want: true
- note: starts_with_false
args: ["hello world", "world"]
want: false
- note: starts_with_case_insensitive
args: ["Hello World", "hello"]
want: true
- note: starts_with_empty_needle
args: ["hello", ""]
want: true
- note: starts_with_empty_haystack
args: ["", "hello"]
want: false
- note: starts_with_both_empty
args: ["", ""]
want: true
- note: starts_with_exact_match
args: ["hello", "hello"]
want: true
- note: starts_with_longer_needle
args: ["hi", "hello"]
want: false

View File

@@ -0,0 +1,38 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
# Tests for azure.policy.fn.string
builtin: azure.policy.fn.string
cases:
- note: string_from_string
args: ["hello"]
want: "hello"
- note: string_from_integer
args: [42]
want: "42"
- note: string_from_negative
args: [-7]
want: "-7"
- note: string_from_bool_true
args: [true]
want: "true"
- note: string_from_bool_false
args: [false]
want: "false"
- note: string_from_null
args: [null]
want: "null"
- note: string_from_zero
args: [0]
want: "0"
- note: string_from_empty
args: [""]
want: ""

View File

@@ -0,0 +1,48 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
# Tests for azure.policy.fn.take
builtin: azure.policy.fn.take
cases:
# ── Arrays ──────────────────────────────────────────────────────────
- note: take_array_basic
args: [[1, 2, 3, 4, 5], 3]
want: [1, 2, 3]
- note: take_array_zero
args: [[1, 2, 3], 0]
want: []
- note: take_array_more_than_length
args: [[1, 2], 5]
want: [1, 2]
- note: take_array_all
args: [[1, 2, 3], 3]
want: [1, 2, 3]
- note: take_array_one
args: [[10, 20, 30], 1]
want: [10]
- note: take_array_empty
args: [[], 3]
want: []
# ── Strings ─────────────────────────────────────────────────────────
- note: take_string_basic
args: ["hello world", 5]
want: "hello"
- note: take_string_zero
args: ["hello", 0]
want: ""
- note: take_string_more_than_length
args: ["hi", 10]
want: "hi"
- note: take_string_one
args: ["hello", 1]
want: "h"

View File

@@ -0,0 +1,38 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
# Tests for azure.policy.fn.trim
builtin: azure.policy.fn.trim
cases:
- note: trim_whitespace
args: [" hello "]
want: "hello"
- note: trim_tabs_and_newlines
args: ["\thello\n"]
want: "hello"
- note: trim_no_whitespace
args: ["hello"]
want: "hello"
- note: trim_empty
args: [""]
want: ""
- note: trim_only_whitespace
args: [" "]
want: ""
- note: trim_left_only
args: [" hello"]
want: "hello"
- note: trim_right_only
args: ["hello "]
want: "hello"
- note: trim_inner_whitespace_preserved
args: [" hello world "]
want: "hello world"

View File

@@ -0,0 +1,51 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
# Tests for azure.policy.fn.try_get
builtin: azure.policy.fn.try_get
cases:
- note: get_existing_key
args:
- name: "test"
value: 42
- "name"
want: "test"
- note: get_missing_key
args:
- name: "test"
- "missing"
want_null: true
- note: get_array_index
args: [["a", "b", "c"], 1]
want: "b"
- note: get_array_out_of_bounds
args: [["a", "b"], 5]
want_null: true
- note: get_from_non_container
args: ["not_an_object", "key"]
want_null: true
- note: get_null_item
args: [null, "key"]
want_null: true
- note: get_nested_value
args:
- data:
inner: true
- "data"
want:
inner: true
- note: get_number_item
args: [42, "key"]
want_null: true
- note: get_array_first
args: [[10, 20, 30], 0]
want: 10

View File

@@ -0,0 +1,38 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
# Tests for azure.policy.fn.try_index_from_end
builtin: azure.policy.fn.try_index_from_end
cases:
- note: last_element
args: [["a", "b", "c", "d"], 1]
want: "d"
- note: second_from_end
args: [["a", "b", "c", "d"], 2]
want: "c"
- note: first_element_via_end
args: [["a", "b", "c", "d"], 4]
want: "a"
- note: index_zero_returns_null
args: [["a", "b"], 0]
want_null: true
- note: index_exceeds_length_returns_null
args: [["a", "b"], 5]
want_null: true
- note: non_array_returns_null
args: ["not_an_array", 1]
want_null: true
- note: single_element
args: [["only"], 1]
want: "only"
- note: numeric_array
args: [[10, 20, 30], 2]
want: 20

View File

@@ -0,0 +1,89 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
# Tests for azure.policy.fn.union
builtin: azure.policy.fn.union
cases:
# ── Array union ─────────────────────────────────────────────────────
- note: union_arrays_basic
args: [[1, 2], [2, 3]]
want: [1, 2, 3]
- note: union_arrays_no_overlap
args: [[1, 2], [3, 4]]
want: [1, 2, 3, 4]
- note: union_arrays_identical
args: [[1, 2], [1, 2]]
want: [1, 2]
- note: union_arrays_empty
args: [[], [1, 2]]
want: [1, 2]
- note: union_arrays_both_empty
args: [[], []]
want: []
- note: union_arrays_three
args: [[1], [2], [3]]
want: [1, 2, 3]
- note: union_arrays_strings
args: [["a", "b"], ["b", "c"]]
want: ["a", "b", "c"]
# ── Object union ────────────────────────────────────────────────────
- note: union_objects_merge
args: [{"a": 1}, {"b": 2}]
want: {"a": 1, "b": 2}
- note: union_objects_overwrite
args: [{"a": 1, "b": 2}, {"b": 20, "c": 30}]
want: {"a": 1, "b": 20, "c": 30}
- note: union_objects_empty
args: [{}, {"a": 1}]
want: {"a": 1}
- note: union_objects_recursive_merge
args:
- outer:
inner_a: 1
inner_b: 2
- outer:
inner_b: 20
inner_c: 30
want:
outer:
inner_a: 1
inner_b: 20
inner_c: 30
- note: union_objects_deeply_nested_merge
args:
- level1:
level2:
a: 1
b: 2
- level1:
level2:
b: 20
c: 30
want:
level1:
level2:
a: 1
b: 20
c: 30
- note: union_objects_array_replaces_not_merges
args:
- data:
items: [1, 2, 3]
- data:
items: [4, 5]
want:
data:
items: [4, 5]

View File

@@ -0,0 +1,54 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
# Tests for azure.policy.fn.uri
builtin: azure.policy.fn.uri
cases:
- note: base_with_trailing_slash
args: ["https://example.com/", "path/to/resource"]
want: "https://example.com/path/to/resource"
- note: base_without_trailing_slash
args: ["https://example.com/base", "relative"]
want: "https://example.com/relative"
- note: relative_is_absolute_url
args: ["https://example.com/base", "https://other.com/path"]
want: "https://other.com/path"
- note: relative_with_leading_slash
args: ["https://example.com/", "/path"]
want: "https://example.com/path"
- note: base_with_path_components
args: ["https://example.com/a/b/c", "d"]
want: "https://example.com/a/b/d"
- note: non_string_base
args: [42, "relative"]
want_undefined: true
- note: non_string_relative
args: ["https://example.com/", 42]
want_undefined: true
- note: simple_base
args: ["https://example.com/", "api/v1"]
want: "https://example.com/api/v1"
- note: authority_only_base
args: ["https://example.com", "api/v1"]
want: "https://example.com/api/v1"
- note: authority_only_base_with_leading_slash
args: ["https://example.com", "/api/v1"]
want: "https://example.com/api/v1"
- note: uri_relative_with_query_string
args: ["https://example.com/api", "v2?key=value"]
want: "https://example.com/v2?key=value"
- note: uri_relative_with_fragment
args: ["https://example.com/docs/", "page#section"]
want: "https://example.com/docs/page#section"

View File

@@ -0,0 +1,30 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
# Tests for azure.policy.fn.uri_component and azure.policy.fn.uri_component_to_string
builtin: azure.policy.fn.uri_component
cases:
- note: uri_encode_simple
args: ["hello world"]
want: "hello%20world"
- note: uri_encode_special_chars
args: ["a=b&c=d"]
want: "a%3Db%26c%3Dd"
- note: uri_encode_already_safe
args: ["hello"]
want: "hello"
- note: uri_encode_empty
args: [""]
want: ""
- note: uri_encode_slash
args: ["path/to/resource"]
want: "path%2Fto%2Fresource"
- note: uri_encode_spaces_and_plus
args: ["hello+world 2"]
want: "hello%2Bworld%202"

View File

@@ -0,0 +1,26 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
# Tests for azure.policy.fn.uri_component_to_string
builtin: azure.policy.fn.uri_component_to_string
cases:
- note: uri_decode_simple
args: ["hello%20world"]
want: "hello world"
- note: uri_decode_special_chars
args: ["a%3Db%26c%3Dd"]
want: "a=b&c=d"
- note: uri_decode_no_encoding
args: ["hello"]
want: "hello"
- note: uri_decode_empty
args: [""]
want: ""
- note: uri_decode_plus_literal
args: ["hello%2Bworld"]
want: "hello+world"

View File

@@ -0,0 +1,215 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
//! YAML-based test runner for Azure Policy builtins.
//!
//! Each YAML file contains a `builtin` field naming the function under test
//! and a list of `cases`. Every case specifies `args` (positional arguments)
//! and either `want` (expected return value) or `want_undefined` (the builtin
//! should return `Value::Undefined`).
//!
//! Tests call builtins directly via the registry (`BUILTINS` map) instead of
//! going through Rego evaluation. This avoids issues with builtin names that
//! collide with Rego keywords (e.g. `azure.policy.if`).
use anyhow::{bail, Result};
use regorus::unstable::{Source, Span, BUILTINS};
use regorus::Value;
use serde::Deserialize;
use test_generator::test_resources;
// ── YAML schema ───────────────────────────────────────────────────────
#[derive(Debug, Deserialize)]
struct YamlTestFile {
/// Dotted builtin name, e.g. `azure.policy.fn.split`.
builtin: String,
cases: Vec<TestCase>,
}
#[derive(Debug, Deserialize)]
struct TestCase {
/// Short human-readable label.
note: String,
/// Positional arguments fed to the builtin.
args: Vec<serde_yaml::Value>,
/// Expected return value (`null` for JSON null).
want: Option<serde_yaml::Value>,
/// If true, the builtin is expected to return null.
/// (Needed because `want: null` in YAML deserializes as Option::None.)
#[serde(default)]
want_null: bool,
/// If true, the builtin is expected to produce Undefined (no result).
#[serde(default)]
want_undefined: bool,
/// If set, the builtin should return an error containing this substring.
want_error: Option<String>,
/// Skip this case.
#[serde(default)]
skip: bool,
}
// ── Helpers ───────────────────────────────────────────────────────────
/// Convert a serde_yaml::Value to a regorus Value.
fn yaml_to_value(v: &serde_yaml::Value) -> Value {
match v {
serde_yaml::Value::Null => Value::Null,
serde_yaml::Value::Bool(b) => Value::Bool(*b),
serde_yaml::Value::Number(n) => {
if let Some(i) = n.as_i64() {
Value::from(i)
} else if let Some(f) = n.as_f64() {
Value::from(f)
} else {
panic!("unsupported YAML numeric representation: {n:?}")
}
}
serde_yaml::Value::String(s) => Value::String(s.as_str().into()),
serde_yaml::Value::Sequence(items) => {
let vals: Vec<Value> = items.iter().map(yaml_to_value).collect();
Value::from(vals)
}
serde_yaml::Value::Mapping(map) => {
let mut obj = Value::new_object();
{
let m = obj.as_object_mut().unwrap();
for (k, v) in map {
m.insert(yaml_to_value(k), yaml_to_value(v));
}
}
obj
}
serde_yaml::Value::Tagged(t) => yaml_to_value(&t.value),
}
}
/// Create a dummy Span for calling builtins outside of normal evaluation.
fn dummy_span() -> Span {
let source = Source::from_contents("<test>".to_string(), String::new())
.expect("creating dummy source should not fail");
Span {
source,
line: 1,
col: 1,
start: 0,
end: 0,
}
}
// ── Test runner ───────────────────────────────────────────────────────
fn run_yaml_test(path: &str) -> Result<()> {
let content = std::fs::read_to_string(path)?;
let test_file: YamlTestFile = serde_yaml::from_str(&content)?;
let filter = std::env::var("TEST_CASE_FILTER").ok();
// Look up the builtin function once for all cases.
let builtin_entry = BUILTINS.get(test_file.builtin.as_str()).unwrap_or_else(|| {
panic!(
"builtin {:?} not found in BUILTINS registry",
test_file.builtin
)
});
let builtin_fn = builtin_entry.0;
let span = dummy_span();
for case in &test_file.cases {
if case.skip {
continue;
}
if let Some(ref f) = filter {
if !case.note.contains(f.as_str()) {
continue;
}
}
// Convert YAML args to Value args.
let args: Vec<Value> = case.args.iter().map(yaml_to_value).collect();
// Call the builtin directly.
let call_result = builtin_fn(&span, &[], &args, false);
// Check error expectations.
if let Some(ref want_err) = case.want_error {
match call_result {
Err(e) => {
let msg = format!("{e:#}");
assert!(
msg.contains(want_err.as_str()),
"[{builtin} / {note}] expected error containing {want_err:?}, got: {msg}",
builtin = test_file.builtin,
note = case.note,
);
}
Ok(ref v) if matches!(v, Value::Undefined) => {
// `want_error` specifically expects an error message string;
// Undefined is not acceptable here — bail.
bail!(
"[{builtin} / {note}] expected error containing {want_err:?} but got Undefined",
builtin = test_file.builtin,
note = case.note,
);
}
Ok(v) => {
bail!(
"[{builtin} / {note}] expected error containing {want_err:?} but got: {v}",
builtin = test_file.builtin,
note = case.note,
);
}
}
continue;
}
// Not expecting an error — unwrap the result.
let actual = call_result.map_err(|e| {
anyhow::anyhow!(
"[{builtin} / {note}] builtin returned error: {e:#}",
builtin = test_file.builtin,
note = case.note,
)
})?;
if case.want_undefined {
assert!(
actual == Value::Undefined,
"[{builtin} / {note}] expected Undefined but got: {actual}",
builtin = test_file.builtin,
note = case.note,
);
continue;
}
// We expect a concrete result.
let expected = if case.want_null {
Value::Null
} else {
let want = case.want.as_ref().unwrap_or_else(|| {
panic!(
"[{} / {}] test case must have `want`, `want_null`, or `want_undefined`",
test_file.builtin, case.note
)
});
yaml_to_value(want)
};
assert!(
actual == expected,
"[{builtin} / {note}]\n expected: {expected}\n actual: {actual}",
builtin = test_file.builtin,
note = case.note,
);
}
Ok(())
}
// ── Test entry point ──────────────────────────────────────────────────
#[test_resources("tests/azure_policy_builtins/cases/*.yaml")]
fn azure_policy_builtin_yaml(path: &str) {
run_yaml_test(path).unwrap();
}

View File

@@ -1,6 +1,9 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
#[cfg(feature = "azure_policy")]
mod azure_policy_builtins;
#[cfg(feature = "coverage")]
mod coverage;