Files
regorus/src/builtins/azure_policy/template_functions_misc.rs
Anand Krishnamoorthi 5b60daabd9 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>
2026-03-25 17:32:39 -05:00

198 lines
7.2 KiB
Rust

// 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
}