mirror of
https://github.com/microsoft/regorus.git
synced 2026-08-05 02:16:11 +00:00
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:
committed by
GitHub
parent
f69974dc1b
commit
5b60daabd9
151
src/builtins/azure_policy/helpers.rs
Normal file
151
src/builtins/azure_policy/helpers.rs
Normal 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 ¤t {
|
||||
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
|
||||
}
|
||||
65
src/builtins/azure_policy/mod.rs
Normal file
65
src/builtins/azure_policy/mod.rs
Normal 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);
|
||||
}
|
||||
116
src/builtins/azure_policy/operators.rs
Normal file
116
src/builtins/azure_policy/operators.rs
Normal 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 = ¶ms_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())
|
||||
}
|
||||
}
|
||||
372
src/builtins/azure_policy/template_functions.rs
Normal file
372
src/builtins/azure_policy/template_functions.rs
Normal 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)))
|
||||
}
|
||||
317
src/builtins/azure_policy/template_functions_collection.rs
Normal file
317
src/builtins/azure_policy/template_functions_collection.rs
Normal 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
|
||||
}
|
||||
687
src/builtins/azure_policy/template_functions_datetime.rs
Normal file
687
src/builtins/azure_policy/template_functions_datetime.rs
Normal 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))
|
||||
}
|
||||
329
src/builtins/azure_policy/template_functions_encoding.rs
Normal file
329
src/builtins/azure_policy/template_functions_encoding.rs
Normal 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)))
|
||||
}
|
||||
197
src/builtins/azure_policy/template_functions_misc.rs
Normal file
197
src/builtins/azure_policy/template_functions_misc.rs
Normal 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
|
||||
}
|
||||
155
src/builtins/azure_policy/template_functions_numeric.rs
Normal file
155
src/builtins/azure_policy/template_functions_numeric.rs
Normal 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
|
||||
}
|
||||
474
src/builtins/azure_policy/template_functions_string.rs
Normal file
474
src/builtins/azure_policy/template_functions_string.rs
Normal 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(())
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
6
src/languages/azure_policy/mod.rs
Normal file
6
src/languages/azure_policy/mod.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
// Copyright (c) Microsoft Corporation.
|
||||
// Licensed under the MIT License.
|
||||
|
||||
//! Azure Policy language support.
|
||||
|
||||
pub mod strings;
|
||||
223
src/languages/azure_policy/strings/case_fold.rs
Normal file
223
src/languages/azure_policy/strings/case_fold.rs
Normal 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", "ffi"));
|
||||
assert!(eq("ffi", "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");
|
||||
}
|
||||
}
|
||||
86
src/languages/azure_policy/strings/keys.rs
Normal file
86
src/languages/azure_policy/strings/keys.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
28
src/languages/azure_policy/strings/mod.rs
Normal file
28
src/languages/azure_policy/strings/mod.rs
Normal 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;
|
||||
@@ -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::*;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user