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>
216 lines
7.5 KiB
Rust
216 lines
7.5 KiB
Rust
// Copyright (c) Microsoft Corporation.
|
|
// Licensed under the MIT License.
|
|
|
|
//! YAML-based test runner for Azure Policy builtins.
|
|
//!
|
|
//! Each YAML file contains a `builtin` field naming the function under test
|
|
//! and a list of `cases`. Every case specifies `args` (positional arguments)
|
|
//! and either `want` (expected return value) or `want_undefined` (the builtin
|
|
//! should return `Value::Undefined`).
|
|
//!
|
|
//! Tests call builtins directly via the registry (`BUILTINS` map) instead of
|
|
//! going through Rego evaluation. This avoids issues with builtin names that
|
|
//! collide with Rego keywords (e.g. `azure.policy.if`).
|
|
|
|
use anyhow::{bail, Result};
|
|
use regorus::unstable::{Source, Span, BUILTINS};
|
|
use regorus::Value;
|
|
use serde::Deserialize;
|
|
use test_generator::test_resources;
|
|
|
|
// ── YAML schema ───────────────────────────────────────────────────────
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct YamlTestFile {
|
|
/// Dotted builtin name, e.g. `azure.policy.fn.split`.
|
|
builtin: String,
|
|
cases: Vec<TestCase>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct TestCase {
|
|
/// Short human-readable label.
|
|
note: String,
|
|
/// Positional arguments fed to the builtin.
|
|
args: Vec<serde_yaml::Value>,
|
|
/// Expected return value (`null` for JSON null).
|
|
want: Option<serde_yaml::Value>,
|
|
/// If true, the builtin is expected to return null.
|
|
/// (Needed because `want: null` in YAML deserializes as Option::None.)
|
|
#[serde(default)]
|
|
want_null: bool,
|
|
/// If true, the builtin is expected to produce Undefined (no result).
|
|
#[serde(default)]
|
|
want_undefined: bool,
|
|
/// If set, the builtin should return an error containing this substring.
|
|
want_error: Option<String>,
|
|
/// Skip this case.
|
|
#[serde(default)]
|
|
skip: bool,
|
|
}
|
|
|
|
// ── Helpers ───────────────────────────────────────────────────────────
|
|
|
|
/// Convert a serde_yaml::Value to a regorus Value.
|
|
fn yaml_to_value(v: &serde_yaml::Value) -> Value {
|
|
match v {
|
|
serde_yaml::Value::Null => Value::Null,
|
|
serde_yaml::Value::Bool(b) => Value::Bool(*b),
|
|
serde_yaml::Value::Number(n) => {
|
|
if let Some(i) = n.as_i64() {
|
|
Value::from(i)
|
|
} else if let Some(f) = n.as_f64() {
|
|
Value::from(f)
|
|
} else {
|
|
panic!("unsupported YAML numeric representation: {n:?}")
|
|
}
|
|
}
|
|
serde_yaml::Value::String(s) => Value::String(s.as_str().into()),
|
|
serde_yaml::Value::Sequence(items) => {
|
|
let vals: Vec<Value> = items.iter().map(yaml_to_value).collect();
|
|
Value::from(vals)
|
|
}
|
|
serde_yaml::Value::Mapping(map) => {
|
|
let mut obj = Value::new_object();
|
|
{
|
|
let m = obj.as_object_mut().unwrap();
|
|
for (k, v) in map {
|
|
m.insert(yaml_to_value(k), yaml_to_value(v));
|
|
}
|
|
}
|
|
obj
|
|
}
|
|
serde_yaml::Value::Tagged(t) => yaml_to_value(&t.value),
|
|
}
|
|
}
|
|
|
|
/// Create a dummy Span for calling builtins outside of normal evaluation.
|
|
fn dummy_span() -> Span {
|
|
let source = Source::from_contents("<test>".to_string(), String::new())
|
|
.expect("creating dummy source should not fail");
|
|
Span {
|
|
source,
|
|
line: 1,
|
|
col: 1,
|
|
start: 0,
|
|
end: 0,
|
|
}
|
|
}
|
|
|
|
// ── Test runner ───────────────────────────────────────────────────────
|
|
|
|
fn run_yaml_test(path: &str) -> Result<()> {
|
|
let content = std::fs::read_to_string(path)?;
|
|
let test_file: YamlTestFile = serde_yaml::from_str(&content)?;
|
|
|
|
let filter = std::env::var("TEST_CASE_FILTER").ok();
|
|
|
|
// Look up the builtin function once for all cases.
|
|
let builtin_entry = BUILTINS.get(test_file.builtin.as_str()).unwrap_or_else(|| {
|
|
panic!(
|
|
"builtin {:?} not found in BUILTINS registry",
|
|
test_file.builtin
|
|
)
|
|
});
|
|
|
|
let builtin_fn = builtin_entry.0;
|
|
let span = dummy_span();
|
|
|
|
for case in &test_file.cases {
|
|
if case.skip {
|
|
continue;
|
|
}
|
|
if let Some(ref f) = filter {
|
|
if !case.note.contains(f.as_str()) {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// Convert YAML args to Value args.
|
|
let args: Vec<Value> = case.args.iter().map(yaml_to_value).collect();
|
|
|
|
// Call the builtin directly.
|
|
let call_result = builtin_fn(&span, &[], &args, false);
|
|
|
|
// Check error expectations.
|
|
if let Some(ref want_err) = case.want_error {
|
|
match call_result {
|
|
Err(e) => {
|
|
let msg = format!("{e:#}");
|
|
assert!(
|
|
msg.contains(want_err.as_str()),
|
|
"[{builtin} / {note}] expected error containing {want_err:?}, got: {msg}",
|
|
builtin = test_file.builtin,
|
|
note = case.note,
|
|
);
|
|
}
|
|
Ok(ref v) if matches!(v, Value::Undefined) => {
|
|
// `want_error` specifically expects an error message string;
|
|
// Undefined is not acceptable here — bail.
|
|
bail!(
|
|
"[{builtin} / {note}] expected error containing {want_err:?} but got Undefined",
|
|
builtin = test_file.builtin,
|
|
note = case.note,
|
|
);
|
|
}
|
|
Ok(v) => {
|
|
bail!(
|
|
"[{builtin} / {note}] expected error containing {want_err:?} but got: {v}",
|
|
builtin = test_file.builtin,
|
|
note = case.note,
|
|
);
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// Not expecting an error — unwrap the result.
|
|
let actual = call_result.map_err(|e| {
|
|
anyhow::anyhow!(
|
|
"[{builtin} / {note}] builtin returned error: {e:#}",
|
|
builtin = test_file.builtin,
|
|
note = case.note,
|
|
)
|
|
})?;
|
|
|
|
if case.want_undefined {
|
|
assert!(
|
|
actual == Value::Undefined,
|
|
"[{builtin} / {note}] expected Undefined but got: {actual}",
|
|
builtin = test_file.builtin,
|
|
note = case.note,
|
|
);
|
|
continue;
|
|
}
|
|
|
|
// We expect a concrete result.
|
|
let expected = if case.want_null {
|
|
Value::Null
|
|
} else {
|
|
let want = case.want.as_ref().unwrap_or_else(|| {
|
|
panic!(
|
|
"[{} / {}] test case must have `want`, `want_null`, or `want_undefined`",
|
|
test_file.builtin, case.note
|
|
)
|
|
});
|
|
yaml_to_value(want)
|
|
};
|
|
|
|
assert!(
|
|
actual == expected,
|
|
"[{builtin} / {note}]\n expected: {expected}\n actual: {actual}",
|
|
builtin = test_file.builtin,
|
|
note = case.note,
|
|
);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
// ── Test entry point ──────────────────────────────────────────────────
|
|
|
|
#[test_resources("tests/azure_policy_builtins/cases/*.yaml")]
|
|
fn azure_policy_builtin_yaml(path: &str) {
|
|
run_yaml_test(path).unwrap();
|
|
}
|