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

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::*;
}