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,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;