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