From 5b60daabd90a788aaef592197c83827311d4a601 Mon Sep 17 00:00:00 2001 From: Anand Krishnamoorthi <35780660+anakrish@users.noreply.github.com> Date: Wed, 25 Mar 2026 17:32:39 -0500 Subject: [PATCH] feat: add Azure Policy builtins with YAML test suite (#630) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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) - 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 * 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 * 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 * 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 * 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 * 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::::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 --------- Signed-off-by: Anand Krishnamoorthi --- Cargo.lock | 31 + Cargo.toml | 3 +- bindings/ffi/Cargo.lock | 31 + bindings/ruby/Cargo.lock | 1 + src/builtins/azure_policy/helpers.rs | 151 ++++ src/builtins/azure_policy/mod.rs | 65 ++ src/builtins/azure_policy/operators.rs | 116 +++ .../azure_policy/template_functions.rs | 372 ++++++++++ .../template_functions_collection.rs | 317 ++++++++ .../template_functions_datetime.rs | 687 ++++++++++++++++++ .../template_functions_encoding.rs | 329 +++++++++ .../azure_policy/template_functions_misc.rs | 197 +++++ .../template_functions_numeric.rs | 155 ++++ .../azure_policy/template_functions_string.rs | 474 ++++++++++++ src/builtins/mod.rs | 4 + src/languages/azure_policy/mod.rs | 6 + .../azure_policy/strings/case_fold.rs | 223 ++++++ src/languages/azure_policy/strings/keys.rs | 86 +++ src/languages/azure_policy/strings/mod.rs | 28 + src/lib.rs | 4 + .../azure_policy_builtins/cases/add_days.yaml | 46 ++ tests/azure_policy_builtins/cases/array.yaml | 34 + tests/azure_policy_builtins/cases/base64.yaml | 38 + .../cases/base64_to_json.yaml | 39 + .../cases/base64_to_string.yaml | 30 + tests/azure_policy_builtins/cases/bool.yaml | 70 ++ .../azure_policy_builtins/cases/coalesce.yaml | 42 ++ .../cases/create_object.yaml | 34 + .../azure_policy_builtins/cases/data_uri.yaml | 18 + .../cases/data_uri_to_string.yaml | 18 + .../cases/date_time_add.yaml | 161 ++++ .../cases/date_time_from_epoch.yaml | 30 + .../cases/date_time_to_epoch.yaml | 46 ++ tests/azure_policy_builtins/cases/empty.yaml | 55 ++ .../cases/ends_with.yaml | 38 + tests/azure_policy_builtins/cases/first.yaml | 44 ++ tests/azure_policy_builtins/cases/float.yaml | 38 + tests/azure_policy_builtins/cases/format.yaml | 131 ++++ .../cases/get_parameter.yaml | 46 ++ tests/azure_policy_builtins/cases/if.yaml | 38 + .../cases/index_from_end.yaml | 42 ++ .../azure_policy_builtins/cases/index_of.yaml | 54 ++ tests/azure_policy_builtins/cases/int.yaml | 57 ++ .../azure_policy_builtins/cases/int_div.yaml | 38 + .../azure_policy_builtins/cases/int_mod.yaml | 38 + .../cases/intersection.yaml | 58 ++ .../cases/ip_range_contains.yaml | 65 ++ tests/azure_policy_builtins/cases/items.yaml | 40 + tests/azure_policy_builtins/cases/join.yaml | 42 ++ tests/azure_policy_builtins/cases/json.yaml | 51 ++ tests/azure_policy_builtins/cases/last.yaml | 40 + .../cases/last_index_of.yaml | 34 + .../cases/logic_all.yaml | 38 + .../cases/logic_any.yaml | 34 + tests/azure_policy_builtins/cases/max.yaml | 42 ++ tests/azure_policy_builtins/cases/min.yaml | 44 ++ .../azure_policy_builtins/cases/pad_left.yaml | 38 + tests/azure_policy_builtins/cases/range.yaml | 38 + .../cases/resolve_field.yaml | 49 ++ tests/azure_policy_builtins/cases/skip.yaml | 48 ++ tests/azure_policy_builtins/cases/split.yaml | 61 ++ .../cases/starts_with.yaml | 38 + tests/azure_policy_builtins/cases/string.yaml | 38 + tests/azure_policy_builtins/cases/take.yaml | 48 ++ tests/azure_policy_builtins/cases/trim.yaml | 38 + .../azure_policy_builtins/cases/try_get.yaml | 51 ++ .../cases/try_index_from_end.yaml | 38 + tests/azure_policy_builtins/cases/union.yaml | 89 +++ tests/azure_policy_builtins/cases/uri.yaml | 54 ++ .../cases/uri_component.yaml | 30 + .../cases/uri_component_to_string.yaml | 26 + tests/azure_policy_builtins/mod.rs | 215 ++++++ tests/mod.rs | 3 + 73 files changed, 5894 insertions(+), 1 deletion(-) create mode 100644 src/builtins/azure_policy/helpers.rs create mode 100644 src/builtins/azure_policy/mod.rs create mode 100644 src/builtins/azure_policy/operators.rs create mode 100644 src/builtins/azure_policy/template_functions.rs create mode 100644 src/builtins/azure_policy/template_functions_collection.rs create mode 100644 src/builtins/azure_policy/template_functions_datetime.rs create mode 100644 src/builtins/azure_policy/template_functions_encoding.rs create mode 100644 src/builtins/azure_policy/template_functions_misc.rs create mode 100644 src/builtins/azure_policy/template_functions_numeric.rs create mode 100644 src/builtins/azure_policy/template_functions_string.rs create mode 100644 src/languages/azure_policy/mod.rs create mode 100644 src/languages/azure_policy/strings/case_fold.rs create mode 100644 src/languages/azure_policy/strings/keys.rs create mode 100644 src/languages/azure_policy/strings/mod.rs create mode 100644 tests/azure_policy_builtins/cases/add_days.yaml create mode 100644 tests/azure_policy_builtins/cases/array.yaml create mode 100644 tests/azure_policy_builtins/cases/base64.yaml create mode 100644 tests/azure_policy_builtins/cases/base64_to_json.yaml create mode 100644 tests/azure_policy_builtins/cases/base64_to_string.yaml create mode 100644 tests/azure_policy_builtins/cases/bool.yaml create mode 100644 tests/azure_policy_builtins/cases/coalesce.yaml create mode 100644 tests/azure_policy_builtins/cases/create_object.yaml create mode 100644 tests/azure_policy_builtins/cases/data_uri.yaml create mode 100644 tests/azure_policy_builtins/cases/data_uri_to_string.yaml create mode 100644 tests/azure_policy_builtins/cases/date_time_add.yaml create mode 100644 tests/azure_policy_builtins/cases/date_time_from_epoch.yaml create mode 100644 tests/azure_policy_builtins/cases/date_time_to_epoch.yaml create mode 100644 tests/azure_policy_builtins/cases/empty.yaml create mode 100644 tests/azure_policy_builtins/cases/ends_with.yaml create mode 100644 tests/azure_policy_builtins/cases/first.yaml create mode 100644 tests/azure_policy_builtins/cases/float.yaml create mode 100644 tests/azure_policy_builtins/cases/format.yaml create mode 100644 tests/azure_policy_builtins/cases/get_parameter.yaml create mode 100644 tests/azure_policy_builtins/cases/if.yaml create mode 100644 tests/azure_policy_builtins/cases/index_from_end.yaml create mode 100644 tests/azure_policy_builtins/cases/index_of.yaml create mode 100644 tests/azure_policy_builtins/cases/int.yaml create mode 100644 tests/azure_policy_builtins/cases/int_div.yaml create mode 100644 tests/azure_policy_builtins/cases/int_mod.yaml create mode 100644 tests/azure_policy_builtins/cases/intersection.yaml create mode 100644 tests/azure_policy_builtins/cases/ip_range_contains.yaml create mode 100644 tests/azure_policy_builtins/cases/items.yaml create mode 100644 tests/azure_policy_builtins/cases/join.yaml create mode 100644 tests/azure_policy_builtins/cases/json.yaml create mode 100644 tests/azure_policy_builtins/cases/last.yaml create mode 100644 tests/azure_policy_builtins/cases/last_index_of.yaml create mode 100644 tests/azure_policy_builtins/cases/logic_all.yaml create mode 100644 tests/azure_policy_builtins/cases/logic_any.yaml create mode 100644 tests/azure_policy_builtins/cases/max.yaml create mode 100644 tests/azure_policy_builtins/cases/min.yaml create mode 100644 tests/azure_policy_builtins/cases/pad_left.yaml create mode 100644 tests/azure_policy_builtins/cases/range.yaml create mode 100644 tests/azure_policy_builtins/cases/resolve_field.yaml create mode 100644 tests/azure_policy_builtins/cases/skip.yaml create mode 100644 tests/azure_policy_builtins/cases/split.yaml create mode 100644 tests/azure_policy_builtins/cases/starts_with.yaml create mode 100644 tests/azure_policy_builtins/cases/string.yaml create mode 100644 tests/azure_policy_builtins/cases/take.yaml create mode 100644 tests/azure_policy_builtins/cases/trim.yaml create mode 100644 tests/azure_policy_builtins/cases/try_get.yaml create mode 100644 tests/azure_policy_builtins/cases/try_index_from_end.yaml create mode 100644 tests/azure_policy_builtins/cases/union.yaml create mode 100644 tests/azure_policy_builtins/cases/uri.yaml create mode 100644 tests/azure_policy_builtins/cases/uri_component.yaml create mode 100644 tests/azure_policy_builtins/cases/uri_component_to_string.yaml create mode 100644 tests/azure_policy_builtins/mod.rs diff --git a/Cargo.lock b/Cargo.lock index 15f3414..8f9a8ae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -604,6 +604,28 @@ dependencies = [ "cc", ] +[[package]] +name = "icu_casemap" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ca9983e8bf51223c2f89014fa4eaa9e9b336c47f3af0d000538f86f841fba1" +dependencies = [ + "icu_casemap_data", + "icu_collections", + "icu_locale_core", + "icu_properties", + "icu_provider", + "potential_utf", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_casemap_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98d4663d0f99b301033a19e0acf94e9d2fa4b107638580165e5a6ccc49ad1450" + [[package]] name = "icu_collections" version = "2.1.1" @@ -612,6 +634,7 @@ checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" dependencies = [ "displaydoc", "potential_utf", + "serde", "yoke", "zerofrom", "zerovec", @@ -625,6 +648,7 @@ checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" dependencies = [ "displaydoc", "litemap", + "serde", "tinystr", "writeable", "zerovec", @@ -678,6 +702,8 @@ checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" dependencies = [ "displaydoc", "icu_locale_core", + "serde", + "stable_deref_trait", "writeable", "yoke", "zerofrom", @@ -1070,6 +1096,8 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" dependencies = [ + "serde_core", + "writeable", "zerovec", ] @@ -1267,6 +1295,7 @@ dependencies = [ "dashmap", "data-encoding", "globset", + "icu_casemap", "indexmap", "ipnet", "jsonschema", @@ -1511,6 +1540,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" dependencies = [ "displaydoc", + "serde_core", "zerovec", ] @@ -1912,6 +1942,7 @@ version = "0.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" dependencies = [ + "serde", "yoke", "zerofrom", "zerovec-derive", diff --git a/Cargo.toml b/Cargo.toml index a75c947..83183a1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,7 +24,7 @@ default = ["full-opa", "arc", "rvm"] arc = [] ast = [] -azure_policy = ["dep:jsonschema", "arc", "dashmap"] +azure_policy = ["dep:jsonschema", "dep:chrono", "dep:ipnet", "dep:icu_casemap", "arc", "dashmap"] azure-rbac = ["regex", "time", "net"] base64 = ["dep:data-encoding"] base64url = ["dep:data-encoding"] @@ -117,6 +117,7 @@ jsonschema = { version = "0.30.0", default-features = false, optional = true } chrono = { version = "0.4.40", optional = true } chrono-tz = { version = "0.10.1", optional = true } ipnet = { version = "2.11.0", optional = true, default-features = false } +icu_casemap = { version = "2.1", optional = true, default-features = false, features = ["compiled_data"] } serde_yaml = {version = "0.9.16", default-features = false, optional = true } # Specify thread_rng for in order to use random_range diff --git a/bindings/ffi/Cargo.lock b/bindings/ffi/Cargo.lock index 698a6bc..aee1b3b 100644 --- a/bindings/ffi/Cargo.lock +++ b/bindings/ffi/Cargo.lock @@ -450,6 +450,28 @@ dependencies = [ "cc", ] +[[package]] +name = "icu_casemap" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ca9983e8bf51223c2f89014fa4eaa9e9b336c47f3af0d000538f86f841fba1" +dependencies = [ + "icu_casemap_data", + "icu_collections", + "icu_locale_core", + "icu_properties", + "icu_provider", + "potential_utf", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_casemap_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98d4663d0f99b301033a19e0acf94e9d2fa4b107638580165e5a6ccc49ad1450" + [[package]] name = "icu_collections" version = "2.1.1" @@ -458,6 +480,7 @@ checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" dependencies = [ "displaydoc", "potential_utf", + "serde", "yoke", "zerofrom", "zerovec", @@ -471,6 +494,7 @@ checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" dependencies = [ "displaydoc", "litemap", + "serde", "tinystr", "writeable", "zerovec", @@ -524,6 +548,8 @@ checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" dependencies = [ "displaydoc", "icu_locale_core", + "serde", + "stable_deref_trait", "writeable", "yoke", "zerofrom", @@ -840,6 +866,8 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" dependencies = [ + "serde_core", + "writeable", "zerovec", ] @@ -987,6 +1015,7 @@ dependencies = [ "dashmap", "data-encoding", "globset", + "icu_casemap", "indexmap", "ipnet", "jsonschema", @@ -1235,6 +1264,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" dependencies = [ "displaydoc", + "serde_core", "zerovec", ] @@ -1569,6 +1599,7 @@ version = "0.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" dependencies = [ + "serde", "yoke", "zerofrom", "zerovec-derive", diff --git a/bindings/ruby/Cargo.lock b/bindings/ruby/Cargo.lock index b46e74b..b48a999 100644 --- a/bindings/ruby/Cargo.lock +++ b/bindings/ruby/Cargo.lock @@ -936,6 +936,7 @@ dependencies = [ "msvc_spectre_libs", "num-bigint", "num-traits", + "parking_lot", "rand", "regex", "regorus-mimalloc", diff --git a/src/builtins/azure_policy/helpers.rs b/src/builtins/azure_policy/helpers.rs new file mode 100644 index 0000000..bd8a66b --- /dev/null +++ b/src/builtins/azure_policy/helpers.rs @@ -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 { + 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 { + 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::() 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 { + 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 +} diff --git a/src/builtins/azure_policy/mod.rs b/src/builtins/azure_policy/mod.rs new file mode 100644 index 0000000..bdda441 --- /dev/null +++ b/src/builtins/azure_policy/mod.rs @@ -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); +} diff --git a/src/builtins/azure_policy/operators.rs b/src/builtins/azure_policy/operators.rs new file mode 100644 index 0000000..5f95700 --- /dev/null +++ b/src/builtins/azure_policy/operators.rs @@ -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], + args: &[Value], + _strict: bool, +) -> Result { + #[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], + args: &[Value], + _strict: bool, +) -> Result { + #[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], + args: &[Value], + _strict: bool, +) -> Result { + 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], + args: &[Value], + _strict: bool, +) -> Result { + 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], + args: &[Value], + _strict: bool, +) -> Result { + #[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()) + } +} diff --git a/src/builtins/azure_policy/template_functions.rs b/src/builtins/azure_policy/template_functions.rs new file mode 100644 index 0000000..9d9ba0f --- /dev/null +++ b/src/builtins/azure_policy/template_functions.rs @@ -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` 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 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], args: &[Value], _strict: bool) -> Result { + #[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 = 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], args: &[Value], _strict: bool) -> Result { + 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], args: &[Value], _strict: bool) -> Result { + 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], args: &[Value], _strict: bool) -> Result { + 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], + args: &[Value], + _strict: bool, +) -> Result { + #[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], + args: &[Value], + _strict: bool, +) -> Result { + #[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], args: &[Value], _strict: bool) -> Result { + 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], args: &[Value], _strict: bool) -> Result { + 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], args: &[Value], _strict: bool) -> Result { + 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], + args: &[Value], + _strict: bool, +) -> Result { + 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], + args: &[Value], + _strict: bool, +) -> Result { + #[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::() else { + return Ok(Value::Bool(false)); + }; + + if target.contains('/') { + let Ok(target_net) = target.parse::() else { + return Ok(Value::Bool(false)); + }; + return Ok(Value::Bool(net.contains(&target_net))); + } + + let Ok(target_ip) = target.parse::() else { + return Ok(Value::Bool(false)); + }; + Ok(Value::Bool(net.contains(&target_ip))) +} diff --git a/src/builtins/azure_policy/template_functions_collection.rs b/src/builtins/azure_policy/template_functions_collection.rs new file mode 100644 index 0000000..88cd6c8 --- /dev/null +++ b/src/builtins/azure_policy/template_functions_collection.rs @@ -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], + args: &[Value], + _strict: bool, +) -> Result { + 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 = 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 = 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], args: &[Value], _strict: bool) -> Result { + 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::::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], args: &[Value], _strict: bool) -> Result { + #[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], args: &[Value], _strict: bool) -> Result { + #[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], args: &[Value], _strict: bool) -> Result { + #[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], args: &[Value], _strict: bool) -> Result { + let Some(arg) = args.first() else { + return Ok(Value::from(Vec::::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], + args: &[Value], + _strict: bool, +) -> Result { + 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], + args: &[Value], + _strict: bool, +) -> Result { + 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::::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, overlay: &BTreeMap) -> 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 { + 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 { + 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 +} diff --git a/src/builtins/azure_policy/template_functions_datetime.rs b/src/builtins/azure_policy/template_functions_datetime.rs new file mode 100644 index 0000000..398d03e --- /dev/null +++ b/src/builtins/azure_policy/template_functions_datetime.rs @@ -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> { + 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, 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::::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::::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::::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::::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::::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::::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) -> 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, 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 { + 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], + args: &[Value], + _strict: bool, +) -> Result { + 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], + args: &[Value], + _strict: bool, +) -> Result { + let Some(epoch) = args.first().and_then(extract_i64) else { + return Ok(Value::Undefined); + }; + let Some(dt) = DateTime::::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], + args: &[Value], + _strict: bool, +) -> Result { + 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], + args: &[Value], + _strict: bool, +) -> Result { + 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 { + 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, dotnet_fmt: &str) -> Result { + // 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, 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: +fn resolve_standard_format(fmt: &str) -> Result { + 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 { + let mut segments: Vec = Vec::new(); + let mut chrono_buf = String::new(); + let chars: Vec = 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)) +} diff --git a/src/builtins/azure_policy/template_functions_encoding.rs b/src/builtins/azure_policy/template_functions_encoding.rs new file mode 100644 index 0000000..1e8e491 --- /dev/null +++ b/src/builtins/azure_policy/template_functions_encoding.rs @@ -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 { + 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> { + let input = input.trim(); + if input.is_empty() { + return Some(Vec::new()); + } + let bytes: Vec = 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], args: &[Value], _strict: bool) -> Result { + 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], + args: &[Value], + _strict: bool, +) -> Result { + 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], + args: &[Value], + _strict: bool, +) -> Result { + 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 { + 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], args: &[Value], _strict: bool) -> Result { + 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], + args: &[Value], + _strict: bool, +) -> Result { + 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], + args: &[Value], + _strict: bool, +) -> Result { + 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], + args: &[Value], + _strict: bool, +) -> Result { + 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], + args: &[Value], + _strict: bool, +) -> Result { + let Some(s) = args.first().and_then(as_str) else { + return Ok(Value::Undefined); + }; + + // Expected format: data:;base64, + 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))) +} diff --git a/src/builtins/azure_policy/template_functions_misc.rs b/src/builtins/azure_policy/template_functions_misc.rs new file mode 100644 index 0000000..8a66f1d --- /dev/null +++ b/src/builtins/azure_policy/template_functions_misc.rs @@ -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], args: &[Value], _strict: bool) -> Result { + 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], args: &[Value], _strict: bool) -> Result { + #[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 = 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], args: &[Value], _strict: bool) -> Result { + 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::::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], + args: &[Value], + _strict: bool, +) -> Result { + #[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], args: &[Value], _strict: bool) -> Result { + #[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], + args: &[Value], + _strict: bool, +) -> Result { + #[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 { + 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 +} diff --git a/src/builtins/azure_policy/template_functions_numeric.rs b/src/builtins/azure_policy/template_functions_numeric.rs new file mode 100644 index 0000000..91461d0 --- /dev/null +++ b/src/builtins/azure_policy/template_functions_numeric.rs @@ -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], args: &[Value], _strict: bool) -> Result { + 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], args: &[Value], _strict: bool) -> Result { + 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], args: &[Value], _strict: bool) -> Result { + 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], args: &[Value], _strict: bool) -> Result { + #[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], args: &[Value], _strict: bool) -> Result { + #[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 { + 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 +} diff --git a/src/builtins/azure_policy/template_functions_string.rs b/src/builtins/azure_policy/template_functions_string.rs new file mode 100644 index 0000000..be5b5a8 --- /dev/null +++ b/src/builtins/azure_policy/template_functions_string.rs @@ -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], + args: &[Value], + _strict: bool, +) -> Result { + #[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], + args: &[Value], + _strict: bool, +) -> Result { + #[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` 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) { + 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], args: &[Value], _strict: bool) -> Result { + 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], args: &[Value], _strict: bool) -> Result { + 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 = 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 = 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 { + let spec_char = spec.chars().next().unwrap_or('G'); + let precision: Option = 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(()) +} diff --git a/src/builtins/mod.rs b/src/builtins/mod.rs index 8d6bcce..2df2e5b 100644 --- a/src/builtins/mod.rs +++ b/src/builtins/mod.rs @@ -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); diff --git a/src/languages/azure_policy/mod.rs b/src/languages/azure_policy/mod.rs new file mode 100644 index 0000000..477ea7c --- /dev/null +++ b/src/languages/azure_policy/mod.rs @@ -0,0 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Azure Policy language support. + +pub mod strings; diff --git a/src/languages/azure_policy/strings/case_fold.rs b/src/languages/azure_policy/strings/case_fold.rs new file mode 100644 index 0000000..c54e55e --- /dev/null +++ b/src/languages/azure_policy/strings/case_fold.rs @@ -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 = 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"); + } +} diff --git a/src/languages/azure_policy/strings/keys.rs b/src/languages/azure_policy/strings/keys.rs new file mode 100644 index 0000000..638b851 --- /dev/null +++ b/src/languages/azure_policy/strings/keys.rs @@ -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); + } +} diff --git a/src/languages/azure_policy/strings/mod.rs b/src/languages/azure_policy/strings/mod.rs new file mode 100644 index 0000000..71bc55b --- /dev/null +++ b/src/languages/azure_policy/strings/mod.rs @@ -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; diff --git a/src/lib.rs b/src/lib.rs index 8f23add..0056fa0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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::*; } diff --git a/tests/azure_policy_builtins/cases/add_days.yaml b/tests/azure_policy_builtins/cases/add_days.yaml new file mode 100644 index 0000000..de98ca4 --- /dev/null +++ b/tests/azure_policy_builtins/cases/add_days.yaml @@ -0,0 +1,46 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# Tests for azure.policy.fn.add_days +builtin: azure.policy.fn.add_days + +cases: + - note: add_one_day + args: ["2024-01-15T12:00:00Z", 1] + want: "2024-01-16T12:00:00Z" + + - note: add_zero_days + args: ["2024-01-15T12:00:00Z", 0] + want: "2024-01-15T12:00:00Z" + + - note: subtract_one_day + args: ["2024-01-15T12:00:00Z", -1] + want: "2024-01-14T12:00:00Z" + + - note: add_thirty_days + args: ["2024-01-01T00:00:00Z", 30] + want: "2024-01-31T00:00:00Z" + + - note: add_days_across_month + args: ["2024-01-31T00:00:00Z", 1] + want: "2024-02-01T00:00:00Z" + + - note: add_days_leap_year + args: ["2024-02-28T00:00:00Z", 1] + want: "2024-02-29T00:00:00Z" + + - note: add_days_non_leap_year + args: ["2023-02-28T00:00:00Z", 1] + want: "2023-03-01T00:00:00Z" + + - note: invalid_base_datetime + args: ["not-a-date", 5] + want_undefined: true + + - note: non_integer_days + args: ["2024-01-15T12:00:00Z", "abc"] + want_undefined: true + + - note: add_365_days + args: ["2024-01-01T00:00:00Z", 365] + want: "2024-12-31T00:00:00Z" diff --git a/tests/azure_policy_builtins/cases/array.yaml b/tests/azure_policy_builtins/cases/array.yaml new file mode 100644 index 0000000..073d97e --- /dev/null +++ b/tests/azure_policy_builtins/cases/array.yaml @@ -0,0 +1,34 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# Tests for azure.policy.fn.array +builtin: azure.policy.fn.array + +cases: + - note: array_from_int + args: [42] + want: [42] + + - note: array_from_string + args: ["hello"] + want: ["hello"] + + - note: array_from_bool + args: [true] + want: [true] + + - note: array_from_null + args: [null] + want: [null] + + - note: array_from_array + args: [[1, 2, 3]] + want: [1, 2, 3] + + - note: array_from_empty_array + args: [[]] + want: [] + + - note: array_from_object + args: [{"a": 1}] + want: [{"a": 1}] diff --git a/tests/azure_policy_builtins/cases/base64.yaml b/tests/azure_policy_builtins/cases/base64.yaml new file mode 100644 index 0000000..f9c1681 --- /dev/null +++ b/tests/azure_policy_builtins/cases/base64.yaml @@ -0,0 +1,38 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# Tests for azure.policy.fn.base64 and azure.policy.fn.base64_to_string +builtin: azure.policy.fn.base64 + +cases: + - note: base64_encode_hello + args: ["hello"] + want: "aGVsbG8=" + + - note: base64_encode_empty + args: [""] + want: "" + + - note: base64_encode_single_char + args: ["a"] + want: "YQ==" + + - note: base64_encode_two_chars + args: ["ab"] + want: "YWI=" + + - note: base64_encode_three_chars + args: ["abc"] + want: "YWJj" + + - note: base64_encode_json_object + args: ["{\"key\":\"value\"}"] + want: "eyJrZXkiOiJ2YWx1ZSJ9" + + - note: base64_encode_numbers + args: ["12345"] + want: "MTIzNDU=" + + - note: base64_encode_special_chars + args: ["hello world!"] + want: "aGVsbG8gd29ybGQh" diff --git a/tests/azure_policy_builtins/cases/base64_to_json.yaml b/tests/azure_policy_builtins/cases/base64_to_json.yaml new file mode 100644 index 0000000..60fe6e4 --- /dev/null +++ b/tests/azure_policy_builtins/cases/base64_to_json.yaml @@ -0,0 +1,39 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# Tests for azure.policy.fn.base64_to_json +builtin: azure.policy.fn.base64_to_json + +cases: + - note: base64_to_json_object + args: ["eyJrZXkiOiJ2YWx1ZSJ9"] + want: + key: "value" + + - note: base64_to_json_array + args: ["WzEsMiwzXQ=="] + want: [1, 2, 3] + + - note: base64_to_json_string + args: ["ImhlbGxvIg=="] + want: "hello" + + - note: base64_to_json_number + args: ["NDI="] + want: 42 + + - note: base64_to_json_null + args: ["bnVsbA=="] + want_null: true + + - note: base64_to_json_bool + args: ["dHJ1ZQ=="] + want: true + + - note: base64_to_json_invalid_base64 + args: ["!!!"] + want_undefined: true + + - note: base64_to_json_invalid_json + args: ["bm90LWpzb24="] + want_undefined: true diff --git a/tests/azure_policy_builtins/cases/base64_to_string.yaml b/tests/azure_policy_builtins/cases/base64_to_string.yaml new file mode 100644 index 0000000..672efea --- /dev/null +++ b/tests/azure_policy_builtins/cases/base64_to_string.yaml @@ -0,0 +1,30 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# Tests for azure.policy.fn.base64_to_string +builtin: azure.policy.fn.base64_to_string + +cases: + - note: base64_decode_hello + args: ["aGVsbG8="] + want: "hello" + + - note: base64_decode_empty + args: [""] + want: "" + + - note: base64_decode_single_char + args: ["YQ=="] + want: "a" + + - note: base64_decode_no_padding + args: ["YWJj"] + want: "abc" + + - note: base64_decode_json + args: ["eyJrZXkiOiJ2YWx1ZSJ9"] + want: "{\"key\":\"value\"}" + + - note: base64_decode_invalid + args: ["!!!"] + want_undefined: true diff --git a/tests/azure_policy_builtins/cases/bool.yaml b/tests/azure_policy_builtins/cases/bool.yaml new file mode 100644 index 0000000..0051bc6 --- /dev/null +++ b/tests/azure_policy_builtins/cases/bool.yaml @@ -0,0 +1,70 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# Tests for azure.policy.fn.bool +builtin: azure.policy.fn.bool + +cases: + # ── From booleans ─────────────────────────────────────────────────── + - note: bool_from_true + args: [true] + want: true + + - note: bool_from_false + args: [false] + want: false + + # ── From strings ──────────────────────────────────────────────────── + - note: bool_from_string_true + args: ["true"] + want: true + + - note: bool_from_string_false + args: ["false"] + want: false + + - note: bool_from_string_TRUE + args: ["TRUE"] + want: true + + - note: bool_from_string_False + args: ["False"] + want: false + + - note: bool_from_string_1 + args: ["1"] + want: true + + - note: bool_from_string_0 + args: ["0"] + want: false + + - note: bool_from_string_invalid + args: ["yes"] + want_undefined: true + + - note: bool_from_string_empty + args: [""] + want_undefined: true + + # ── From numbers ──────────────────────────────────────────────────── + - note: bool_from_number_1 + args: [1] + want: true + + - note: bool_from_number_0 + args: [0] + want: false + + - note: bool_from_number_negative + args: [-1] + want: true + + - note: bool_from_number_42 + args: [42] + want: true + + # ── Other types ───────────────────────────────────────────────────── + - note: bool_from_null + args: [null] + want_undefined: true diff --git a/tests/azure_policy_builtins/cases/coalesce.yaml b/tests/azure_policy_builtins/cases/coalesce.yaml new file mode 100644 index 0000000..2c3c398 --- /dev/null +++ b/tests/azure_policy_builtins/cases/coalesce.yaml @@ -0,0 +1,42 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# Tests for azure.policy.fn.coalesce +builtin: azure.policy.fn.coalesce + +cases: + - note: coalesce_first_non_null + args: [null, null, "hello"] + want: "hello" + + - note: coalesce_first_is_value + args: ["first", "second"] + want: "first" + + - note: coalesce_all_null + args: [null, null, null] + want_null: true + + - note: coalesce_number + args: [null, 42] + want: 42 + + - note: coalesce_false_is_not_null + args: [null, false, "hello"] + want: false + + - note: coalesce_zero_is_not_null + args: [null, 0, "hello"] + want: 0 + + - note: coalesce_empty_string_is_not_null + args: [null, "", "hello"] + want: "" + + - note: coalesce_single_value + args: [42] + want: 42 + + - note: coalesce_single_null + args: [null] + want_null: true diff --git a/tests/azure_policy_builtins/cases/create_object.yaml b/tests/azure_policy_builtins/cases/create_object.yaml new file mode 100644 index 0000000..bbe0165 --- /dev/null +++ b/tests/azure_policy_builtins/cases/create_object.yaml @@ -0,0 +1,34 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# Tests for azure.policy.fn.create_object +builtin: azure.policy.fn.create_object + +cases: + - note: create_object_single_pair + args: ["name", "Alice"] + want: {"name": "Alice"} + + - note: create_object_two_pairs + args: ["name", "Alice", "age", 30] + want: {"age": 30, "name": "Alice"} + + - note: create_object_mixed_types + args: ["flag", true, "count", 5, "label", "test"] + want: {"count": 5, "flag": true, "label": "test"} + + - note: create_object_empty + args: [] + want: {} + + - note: create_object_nested_value + args: ["data", {"inner": "value"}] + want: {"data": {"inner": "value"}} + + - note: create_object_odd_args_error + args: ["key1", "val1", "key2"] + want_error: "expected an even number of arguments" + + - note: create_object_numeric_key + args: [1, "one", 2, "two"] + want: {1: "one", 2: "two"} diff --git a/tests/azure_policy_builtins/cases/data_uri.yaml b/tests/azure_policy_builtins/cases/data_uri.yaml new file mode 100644 index 0000000..2b68cd2 --- /dev/null +++ b/tests/azure_policy_builtins/cases/data_uri.yaml @@ -0,0 +1,18 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# Tests for azure.policy.fn.data_uri and azure.policy.fn.data_uri_to_string +builtin: azure.policy.fn.data_uri + +cases: + - note: data_uri_hello + args: ["hello"] + want: "data:text/plain;charset=utf8;base64,aGVsbG8=" + + - note: data_uri_empty + args: [""] + want: "data:text/plain;charset=utf8;base64," + + - note: data_uri_json + args: ["{\"key\":\"value\"}"] + want: "data:text/plain;charset=utf8;base64,eyJrZXkiOiJ2YWx1ZSJ9" diff --git a/tests/azure_policy_builtins/cases/data_uri_to_string.yaml b/tests/azure_policy_builtins/cases/data_uri_to_string.yaml new file mode 100644 index 0000000..a4508d0 --- /dev/null +++ b/tests/azure_policy_builtins/cases/data_uri_to_string.yaml @@ -0,0 +1,18 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# Tests for azure.policy.fn.data_uri_to_string +builtin: azure.policy.fn.data_uri_to_string + +cases: + - note: data_uri_to_string_hello + args: ["data:text/plain;charset=utf8;base64,aGVsbG8="] + want: "hello" + + - note: data_uri_to_string_json + args: ["data:text/plain;charset=utf8;base64,eyJrZXkiOiJ2YWx1ZSJ9"] + want: "{\"key\":\"value\"}" + + - note: data_uri_to_string_invalid + args: ["not-a-data-uri"] + want_undefined: true diff --git a/tests/azure_policy_builtins/cases/date_time_add.yaml b/tests/azure_policy_builtins/cases/date_time_add.yaml new file mode 100644 index 0000000..61b05ba --- /dev/null +++ b/tests/azure_policy_builtins/cases/date_time_add.yaml @@ -0,0 +1,161 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# Tests for azure.policy.fn.date_time_add +builtin: azure.policy.fn.date_time_add + +cases: + - note: add_one_day + args: ["2024-01-15T12:00:00Z", "P1D"] + want: "2024-01-16T12:00:00Z" + + - note: add_one_hour + args: ["2024-01-15T12:00:00Z", "PT1H"] + want: "2024-01-15T13:00:00Z" + + - note: add_thirty_minutes + args: ["2024-01-15T12:00:00Z", "PT30M"] + want: "2024-01-15T12:30:00Z" + + - note: add_negative_one_day + args: ["2024-01-15T12:00:00Z", "-P1D"] + want: "2024-01-14T12:00:00Z" + + - note: add_complex_duration + args: ["2024-01-15T12:00:00Z", "P1DT2H30M"] + want: "2024-01-16T14:30:00Z" + + - note: add_one_year_approx + args: ["2024-01-01T00:00:00Z", "P1Y"] + want: "2024-12-31T00:00:00Z" + + - note: add_zero_duration + args: ["2024-06-15T10:30:00Z", "PT0S"] + want: "2024-06-15T10:30:00Z" + + - note: invalid_base_datetime + args: ["not-a-date", "P1D"] + want_undefined: true + + - note: invalid_duration + args: ["2024-01-15T12:00:00Z", "invalid"] + want_undefined: true + + - note: datetime_without_timezone + args: ["2024-01-15T12:00:00", "P1D"] + want: "2024-01-16T12:00:00" + + - note: add_one_week + args: ["2024-01-15T00:00:00Z", "P1W"] + want: "2024-01-22T00:00:00Z" + + - note: space_separated_datetime + args: ["2020-04-07 14:55:59", "P3D"] + want: "2020-04-10 14:55:59" + + - note: space_separated_datetime_with_z + args: ["2020-04-07 14:55:59Z", "P3D"] + want: "2020-04-10 14:55:59Z" + + - note: space_separated_datetime_with_offset + args: ["2020-04-07 14:55:59+05:30", "P1D"] + want: "2020-04-08 14:55:59+05:30" + + - note: output_format_date_only + args: ["2020-04-07T14:55:59Z", "P3Y2M", "yyyy-MM-dd"] + want: "2023-06-06" + + - note: output_format_custom + args: ["2024-01-15T12:00:00Z", "PT1H", "yyyy-MM-dd HH:mm:ss"] + want: "2024-01-15 13:00:00" + + - note: output_format_with_k_utc + args: ["2024-01-15T12:00:00Z", "P0D", "yyyy-MM-ddTHH:mm:ssK"] + want: "2024-01-15T12:00:00Z" + + # Standard .NET format specifiers + - note: output_standard_format_d + args: ["2024-01-15T12:30:00Z", "P1D", "d"] + want: "01/16/2024" + + - note: output_standard_format_G + args: ["2024-01-15T12:30:00Z", "PT1H", "G"] + want: "01/15/2024 13:30:00" + + - note: output_standard_format_o + args: ["2024-01-15T12:00:00Z", "P0D", "o"] + want: "2024-01-15T12:00:00.0000000Z" + + - note: output_standard_format_o_with_nanos + args: ["2024-01-15T12:00:00.123456700Z", "P0D", "o"] + want: "2024-01-15T12:00:00.1234567Z" + + - note: output_standard_format_u + args: ["2024-01-15T12:00:00Z", "P0D", "u"] + want: "2024-01-15 12:00:00Z" + + - note: output_standard_format_u_with_offset + args: ["2024-01-15T12:00:00+05:30", "P0D", "u"] + want: "2024-01-15 06:30:00Z" + + # Fractional seconds preservation (default round-trip, no explicit format) + - note: roundtrip_iso_no_tz_with_frac + args: ["2024-01-15T12:00:00.123", "P0D"] + want: "2024-01-15T12:00:00.123" + + - note: roundtrip_iso_no_tz_with_frac_add + args: ["2024-01-15T12:00:00.500", "P1D"] + want: "2024-01-16T12:00:00.500" + + - note: roundtrip_space_no_tz_with_frac + args: ["2024-01-15 12:00:00.456", "P0D"] + want: "2024-01-15 12:00:00.456" + + - note: roundtrip_space_z_with_frac + args: ["2024-01-15 12:00:00.789Z", "P0D"] + want: "2024-01-15 12:00:00.789Z" + + - note: roundtrip_space_offset_with_frac + args: ["2024-01-15 12:00:00.123+05:30", "P0D"] + want: "2024-01-15 12:00:00.123+05:30" + + - note: roundtrip_no_frac_stays_clean + args: ["2024-01-15T12:00:00", "P0D"] + want: "2024-01-15T12:00:00" + + - note: roundtrip_rfc3339_utc_with_frac + args: ["2024-01-15T12:00:00.123Z", "P0D"] + want: "2024-01-15T12:00:00.123Z" + + - note: roundtrip_rfc3339_offset_with_frac + args: ["2024-01-15T12:00:00.456+05:30", "P0D"] + want: "2024-01-15T12:00:00.456+05:30" + + - note: roundtrip_rfc3339_utc_no_frac_stays_clean + args: ["2024-01-15T12:00:00Z", "P0D"] + want: "2024-01-15T12:00:00Z" + + - note: roundtrip_rfc3339_explicit_zero_offset + args: ["2024-01-15T12:00:00+00:00", "P0D"] + want: "2024-01-15T12:00:00+00:00" + + - note: roundtrip_rfc3339_explicit_zero_offset_with_frac + args: ["2024-01-15T12:00:00.500+00:00", "P0D"] + want: "2024-01-15T12:00:00.500+00:00" + + # Unknown single-char format specifier is a real error + - note: unknown_format_specifier_q + args: ["2024-01-15T12:00:00Z", "P0D", "q"] + want_error: "unrecognised standard format specifier 'q'" + + - note: output_standard_format_U + args: ["2024-01-15T12:00:00+05:30", "P0D", "U"] + want: "Monday, 15 January 2024 06:30:00" + + - note: output_standard_format_s + args: ["2024-01-15T12:00:00Z", "P0D", "s"] + want: "2024-01-15T12:00:00" + + - note: output_standard_format_R + args: ["2024-01-15T12:00:00+05:30", "P0D", "R"] + want: "Mon, 15 Jan 2024 06:30:00 GMT" diff --git a/tests/azure_policy_builtins/cases/date_time_from_epoch.yaml b/tests/azure_policy_builtins/cases/date_time_from_epoch.yaml new file mode 100644 index 0000000..b0d4849 --- /dev/null +++ b/tests/azure_policy_builtins/cases/date_time_from_epoch.yaml @@ -0,0 +1,30 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# Tests for azure.policy.fn.date_time_from_epoch +builtin: azure.policy.fn.date_time_from_epoch + +cases: + - note: unix_epoch_zero + args: [0] + want: "1970-01-01T00:00:00Z" + + - note: specific_timestamp + args: [1705320000] + want: "2024-01-15T12:00:00Z" + + - note: negative_timestamp + args: [-86400] + want: "1969-12-31T00:00:00Z" + + - note: max_reasonable_timestamp + args: [4102444800] + want: "2100-01-01T00:00:00Z" + + - note: one_second_after_epoch + args: [1] + want: "1970-01-01T00:00:01Z" + + - note: non_number_arg + args: ["hello"] + want_undefined: true diff --git a/tests/azure_policy_builtins/cases/date_time_to_epoch.yaml b/tests/azure_policy_builtins/cases/date_time_to_epoch.yaml new file mode 100644 index 0000000..995b917 --- /dev/null +++ b/tests/azure_policy_builtins/cases/date_time_to_epoch.yaml @@ -0,0 +1,46 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# Tests for azure.policy.fn.date_time_to_epoch +builtin: azure.policy.fn.date_time_to_epoch + +cases: + - note: epoch_zero + args: ["1970-01-01T00:00:00Z"] + want: 0 + + - note: specific_datetime + args: ["2024-01-15T12:00:00Z"] + want: 1705320000 + + - note: datetime_with_offset + args: ["2024-01-15T13:00:00+01:00"] + want: 1705320000 + + - note: before_epoch + args: ["1969-12-31T00:00:00Z"] + want: -86400 + + - note: invalid_datetime + args: ["not-a-date"] + want_undefined: true + + - note: non_string_arg + args: [12345] + want_undefined: true + + - note: datetime_without_tz + args: ["2024-01-15T12:00:00"] + want: 1705320000 + + - note: space_separated_datetime + args: ["2024-01-15 12:00:00"] + want: 1705320000 + + - note: space_separated_datetime_with_z + args: ["2024-01-15 12:00:00Z"] + want: 1705320000 + + - note: space_separated_datetime_with_offset + args: ["2024-01-15 13:00:00+01:00"] + want: 1705320000 diff --git a/tests/azure_policy_builtins/cases/empty.yaml b/tests/azure_policy_builtins/cases/empty.yaml new file mode 100644 index 0000000..599a157 --- /dev/null +++ b/tests/azure_policy_builtins/cases/empty.yaml @@ -0,0 +1,55 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# Tests for azure.policy.fn.empty +builtin: azure.policy.fn.empty + +cases: + # ── Strings ───────────────────────────────────────────────────────── + - note: empty_string_true + args: [""] + want: true + + - note: empty_string_false + args: ["hello"] + want: false + + - note: empty_string_space + args: [" "] + want: false + + # ── Arrays ────────────────────────────────────────────────────────── + - note: empty_array_true + args: [[]] + want: true + + - note: empty_array_false + args: [[1, 2]] + want: false + + # ── Objects ───────────────────────────────────────────────────────── + - note: empty_object_true + args: [{}] + want: true + + - note: empty_object_false + args: [{"a": 1}] + want: false + + # ── Null / special values ─────────────────────────────────────────── + - note: empty_null + args: [null] + want: true + + # ── Numbers and bools (not empty) ────────────────────────────────── + - note: empty_number + args: [0] + want: false + + - note: empty_bool_false + args: [false] + want: false + + - note: empty_bool_true + args: [true] + want: false diff --git a/tests/azure_policy_builtins/cases/ends_with.yaml b/tests/azure_policy_builtins/cases/ends_with.yaml new file mode 100644 index 0000000..c8b94ae --- /dev/null +++ b/tests/azure_policy_builtins/cases/ends_with.yaml @@ -0,0 +1,38 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# Tests for azure.policy.fn.ends_with +builtin: azure.policy.fn.ends_with + +cases: + - note: ends_with_true + args: ["hello world", "world"] + want: true + + - note: ends_with_false + args: ["hello world", "hello"] + want: false + + - note: ends_with_case_insensitive + args: ["Hello World", "WORLD"] + want: true + + - note: ends_with_empty_needle + args: ["hello", ""] + want: true + + - note: ends_with_empty_haystack + args: ["", "hello"] + want: false + + - note: ends_with_both_empty + args: ["", ""] + want: true + + - note: ends_with_exact_match + args: ["hello", "hello"] + want: true + + - note: ends_with_longer_needle + args: ["hi", "hello"] + want: false diff --git a/tests/azure_policy_builtins/cases/first.yaml b/tests/azure_policy_builtins/cases/first.yaml new file mode 100644 index 0000000..de4960b --- /dev/null +++ b/tests/azure_policy_builtins/cases/first.yaml @@ -0,0 +1,44 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# Tests for azure.policy.fn.first +builtin: azure.policy.fn.first + +cases: + # ── Arrays ────────────────────────────────────────────────────────── + - note: first_array_single + args: [[42]] + want: 42 + + - note: first_array_multiple + args: [[10, 20, 30]] + want: 10 + + - note: first_array_strings + args: [["alpha", "beta", "gamma"]] + want: "alpha" + + - note: first_array_mixed + args: [[true, 1, "x"]] + want: true + + - note: first_array_empty + args: [[]] + want_null: true + + # ── Strings ───────────────────────────────────────────────────────── + - note: first_string + args: ["hello"] + want: "h" + + - note: first_string_single_char + args: ["x"] + want: "x" + + - note: first_string_empty + args: [""] + want: "" + + - note: first_string_unicode + args: ["über"] + want: "ü" diff --git a/tests/azure_policy_builtins/cases/float.yaml b/tests/azure_policy_builtins/cases/float.yaml new file mode 100644 index 0000000..b93695e --- /dev/null +++ b/tests/azure_policy_builtins/cases/float.yaml @@ -0,0 +1,38 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# Tests for azure.policy.fn.float +builtin: azure.policy.fn.float + +cases: + - note: float_from_int + args: [42] + want: 42.0 + + - note: float_from_float + args: [3.14] + want: 3.14 + + - note: float_from_zero + args: [0] + want: 0.0 + + - note: float_from_negative + args: [-5] + want: -5.0 + + - note: float_from_string + args: ["3.14"] + want: 3.14 + + - note: float_from_string_int + args: ["42"] + want: 42.0 + + - note: float_from_string_invalid + args: ["hello"] + want_undefined: true + + - note: float_from_null + args: [null] + want_undefined: true diff --git a/tests/azure_policy_builtins/cases/format.yaml b/tests/azure_policy_builtins/cases/format.yaml new file mode 100644 index 0000000..2cdd365 --- /dev/null +++ b/tests/azure_policy_builtins/cases/format.yaml @@ -0,0 +1,131 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# Tests for azure.policy.fn.format +builtin: azure.policy.fn.format + +cases: + - note: format_single_placeholder + args: ["Hello {0}!", "world"] + want: "Hello world!" + + - note: format_multiple_placeholders + args: ["{0} is {1}", "sky", "blue"] + want: "sky is blue" + + - note: format_repeated_placeholder + args: ["{0} and {0}", "hello"] + want: "hello and hello" + + - note: format_number_arg + args: ["Count: {0}", 42] + want: "Count: 42" + + - note: format_bool_arg + args: ["Value: {0}", true] + want: "Value: True" + + - note: format_no_placeholders + args: ["no placeholders"] + want: "no placeholders" + + - note: format_three_args + args: ["{0}-{1}-{2}", "a", "b", "c"] + want: "a-b-c" + + - note: format_missing_placeholder_error + args: ["{0} and {1}", "only_first"] + want_error: "placeholder {1} references argument index 1" + + - note: format_empty_template + args: [""] + want: "" + + - note: format_escaped_braces + args: ["{{literal braces}}"] + want: "{literal braces}" + + - note: format_mixed_escaped_and_placeholder + args: ["{{{0}}}", "value"] + want: "{value}" + + - note: format_alignment_right + args: ["{0,10}", "hi"] + want: " hi" + + - note: format_alignment_left + args: ["{0,-10}", "hi"] + want: "hi " + + - note: format_numeric_fixed_point + args: ["{0:F2}", 3.14159] + want: "3.14" + + - note: format_numeric_with_thousands + args: ["{0:N0}", 1234567] + want: "1,234,567" + + - note: format_float_with_thousands + args: ["{0:N2}", 1234.5678] + want: "1,234.57" + + - note: format_hex_upper + args: ["{0:X}", 255] + want: "FF" + + - note: format_hex_lower_padded + args: ["{0:x4}", 255] + want: "00ff" + + - note: format_decimal_padded + args: ["{0:D5}", 42] + want: "00042" + + - note: format_percent + args: ["{0:P1}", 0.1234] + want: "12.3 %" + + - note: format_unmatched_closing_brace + args: ["hello } world"] + want_error: "unmatched closing brace" + + - note: format_invalid_placeholder_no_index + args: ["{abc}"] + want_error: "invalid placeholder" + + - note: format_unmatched_opening_brace + args: ["{0", "value"] + want_error: "unmatched opening brace" + + - note: format_alignment_non_ascii + args: ["{0,6}", "café"] + want: " café" + + # Invalid alignment clauses (must be rejected) + - note: format_alignment_empty + args: ["{0,}", "hi"] + want_error: "empty alignment value" + + - note: format_alignment_non_numeric + args: ["{0,abc}", "hi"] + want_error: "invalid alignment" + + - note: format_alignment_trailing_junk + args: ["{0, 1x}", "hi"] + want_error: "invalid alignment" + + - note: format_unknown_numeric_specifier_error + args: ["{0:Z}", 42] + want_error: "invalid numeric format specifier" + + - note: format_alignment_width_exceeds_max + args: ["{0,1000000000}", "hi"] + want_error: "exceeds maximum allowed" + + - note: format_fixed_point_higher_precision + args: ["{0:F3}", 1.23456] + want: "1.235" + + - note: format_negative_index_error + args: ["{-1}", "value"] + want_error: "invalid placeholder" diff --git a/tests/azure_policy_builtins/cases/get_parameter.yaml b/tests/azure_policy_builtins/cases/get_parameter.yaml new file mode 100644 index 0000000..a32c230 --- /dev/null +++ b/tests/azure_policy_builtins/cases/get_parameter.yaml @@ -0,0 +1,46 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# Tests for azure.policy.get_parameter +builtin: azure.policy.get_parameter + +cases: + - note: param_from_user_params + args: + - allowedLocations: ["eastus", "westus"] + - allowedLocations: ["centralus"] + - "allowedLocations" + want: ["eastus", "westus"] + + - note: param_falls_back_to_defaults + args: + - {} + - effect: "Deny" + - "effect" + want: "Deny" + + - note: param_not_in_either + args: + - {} + - {} + - "missing" + want_undefined: true + + - note: param_override_default + args: + - maxAge: 90 + - maxAge: 365 + - "maxAge" + want: 90 + + - note: param_with_nested_value + args: + - config: + enabled: true + threshold: 50 + - config: + enabled: false + - "config" + want: + enabled: true + threshold: 50 diff --git a/tests/azure_policy_builtins/cases/if.yaml b/tests/azure_policy_builtins/cases/if.yaml new file mode 100644 index 0000000..3b330b4 --- /dev/null +++ b/tests/azure_policy_builtins/cases/if.yaml @@ -0,0 +1,38 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# Tests for azure.policy.if (ARM template if() function) +builtin: azure.policy.if + +cases: + - note: condition_true_returns_when_true + args: [true, "yes", "no"] + want: "yes" + + - note: condition_false_returns_when_false + args: [false, "yes", "no"] + want: "no" + + - note: true_with_numbers + args: [true, 1, 0] + want: 1 + + - note: false_with_numbers + args: [false, 1, 0] + want: 0 + + - note: true_with_arrays + args: [true, [1, 2], [3, 4]] + want: [1, 2] + + - note: false_with_arrays + args: [false, [1, 2], [3, 4]] + want: [3, 4] + + - note: true_with_null_branches + args: [true, null, "fallback"] + want_null: true + + - note: false_with_null_branch + args: [false, "value", null] + want_null: true diff --git a/tests/azure_policy_builtins/cases/index_from_end.yaml b/tests/azure_policy_builtins/cases/index_from_end.yaml new file mode 100644 index 0000000..c207e79 --- /dev/null +++ b/tests/azure_policy_builtins/cases/index_from_end.yaml @@ -0,0 +1,42 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# Tests for azure.policy.fn.index_from_end +builtin: azure.policy.fn.index_from_end + +cases: + - note: last_element + args: [["a", "b", "c", "d"], 1] + want: "d" + + - note: second_from_end + args: [["a", "b", "c", "d"], 2] + want: "c" + + - note: third_from_end + args: [["a", "b", "c", "d"], 3] + want: "b" + + - note: first_element_via_end + args: [["a", "b", "c", "d"], 4] + want: "a" + + - note: single_element_array + args: [["only"], 1] + want: "only" + + - note: index_zero_error + args: [["a", "b"], 0] + want_error: "out of bounds" + + - note: index_exceeds_length + args: [["a", "b"], 3] + want_error: "out of bounds" + + - note: non_array_arg + args: ["not_an_array", 1] + want_undefined: true + + - note: numeric_array + args: [[10, 20, 30], 1] + want: 30 diff --git a/tests/azure_policy_builtins/cases/index_of.yaml b/tests/azure_policy_builtins/cases/index_of.yaml new file mode 100644 index 0000000..f4aa6ea --- /dev/null +++ b/tests/azure_policy_builtins/cases/index_of.yaml @@ -0,0 +1,54 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# Tests for azure.policy.fn.index_of +builtin: azure.policy.fn.index_of + +cases: + - note: index_of_found + args: ["hello world", "world"] + want: 6 + + - note: index_of_beginning + args: ["hello", "hello"] + want: 0 + + - note: index_of_not_found + args: ["hello", "xyz"] + want: -1 + + - note: index_of_empty_needle + args: ["hello", ""] + want: 0 + + - note: index_of_empty_haystack + args: ["", "hello"] + want: -1 + + - note: index_of_both_empty + args: ["", ""] + want: 0 + + - note: index_of_single_char + args: ["abcdef", "d"] + want: 3 + + - note: index_of_first_occurrence + args: ["abcabc", "bc"] + want: 1 + + - note: index_of_case_insensitive + args: ["Hello", "hello"] + want: 0 + + - note: index_of_case_insensitive_mixed + args: ["Hello World", "WORLD"] + want: 6 + + - note: index_of_unicode_char_index + args: ["café latte", "latte"] + want: 5 + + - note: index_of_unicode_case_fold + args: ["Straße", "STRASSE"] + want: 0 diff --git a/tests/azure_policy_builtins/cases/int.yaml b/tests/azure_policy_builtins/cases/int.yaml new file mode 100644 index 0000000..59ad6b5 --- /dev/null +++ b/tests/azure_policy_builtins/cases/int.yaml @@ -0,0 +1,57 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# Tests for azure.policy.fn.int +builtin: azure.policy.fn.int + +cases: + # ── From numbers ──────────────────────────────────────────────────── + - note: int_from_integer + args: [42] + want: 42 + + - note: int_from_negative + args: [-7] + want: -7 + + - note: int_from_zero + args: [0] + want: 0 + + - note: int_from_float_truncates + args: [3.9] + want: 3 + + - note: int_from_negative_float + args: [-2.7] + want: -2 + + # ── From strings ──────────────────────────────────────────────────── + - note: int_from_string_integer + args: ["42"] + want: 42 + + - note: int_from_string_negative + args: ["-10"] + want: -10 + + - note: int_from_string_float + args: ["3.14"] + want: 3 + + - note: int_from_string_invalid + args: ["hello"] + want_undefined: true + + - note: int_from_string_empty + args: [""] + want_undefined: true + + # ── Other types ──────────────────────────────────────────────────── + - note: int_from_bool + args: [true] + want_undefined: true + + - note: int_from_null + args: [null] + want_undefined: true diff --git a/tests/azure_policy_builtins/cases/int_div.yaml b/tests/azure_policy_builtins/cases/int_div.yaml new file mode 100644 index 0000000..5139aca --- /dev/null +++ b/tests/azure_policy_builtins/cases/int_div.yaml @@ -0,0 +1,38 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# Tests for azure.policy.fn.int_div +builtin: azure.policy.fn.int_div + +cases: + - note: int_div_basic + args: [10, 3] + want: 3 + + - note: int_div_exact + args: [10, 5] + want: 2 + + - note: int_div_one + args: [7, 1] + want: 7 + + - note: int_div_negative + args: [-10, 3] + want: -3 + + - note: int_div_both_negative + args: [-10, -3] + want: 3 + + - note: int_div_zero_numerator + args: [0, 5] + want: 0 + + - note: int_div_by_zero + args: [10, 0] + want_undefined: true + + - note: int_div_large + args: [1000000, 7] + want: 142857 diff --git a/tests/azure_policy_builtins/cases/int_mod.yaml b/tests/azure_policy_builtins/cases/int_mod.yaml new file mode 100644 index 0000000..dd67311 --- /dev/null +++ b/tests/azure_policy_builtins/cases/int_mod.yaml @@ -0,0 +1,38 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# Tests for azure.policy.fn.int_mod +builtin: azure.policy.fn.int_mod + +cases: + - note: int_mod_basic + args: [10, 3] + want: 1 + + - note: int_mod_exact + args: [10, 5] + want: 0 + + - note: int_mod_one + args: [7, 1] + want: 0 + + - note: int_mod_negative + args: [-10, 3] + want: -1 + + - note: int_mod_both_negative + args: [-10, -3] + want: -1 + + - note: int_mod_zero_numerator + args: [0, 5] + want: 0 + + - note: int_mod_by_zero + args: [10, 0] + want_undefined: true + + - note: int_mod_large + args: [1000000, 7] + want: 1 diff --git a/tests/azure_policy_builtins/cases/intersection.yaml b/tests/azure_policy_builtins/cases/intersection.yaml new file mode 100644 index 0000000..afdd87c --- /dev/null +++ b/tests/azure_policy_builtins/cases/intersection.yaml @@ -0,0 +1,58 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# Tests for azure.policy.fn.intersection +builtin: azure.policy.fn.intersection + +cases: + # ── Array intersection ────────────────────────────────────────────── + - note: intersection_arrays_overlap + args: [[1, 2, 3], [2, 3, 4]] + want: [2, 3] + + - note: intersection_arrays_no_overlap + args: [[1, 2], [3, 4]] + want: [] + + - note: intersection_arrays_identical + args: [[1, 2, 3], [1, 2, 3]] + want: [1, 2, 3] + + - note: intersection_arrays_empty_first + args: [[], [1, 2]] + want: [] + + - note: intersection_arrays_empty_second + args: [[1, 2], []] + want: [] + + - note: intersection_arrays_three + args: [[1, 2, 3, 4], [2, 3, 4, 5], [3, 4, 5, 6]] + want: [3, 4] + + - note: intersection_arrays_strings + args: [["a", "b", "c"], ["b", "c", "d"]] + want: ["b", "c"] + + # ── Object intersection ───────────────────────────────────────────── + # Azure semantics: a key is kept only when it exists in ALL objects + # AND the value is the same across all of them. + - note: intersection_objects_same_values + args: [{"a": 1, "b": 2, "c": 3}, {"b": 2, "c": 3, "d": 4}] + want: {"b": 2, "c": 3} + + - note: intersection_objects_different_values + args: [{"a": 1, "b": 2, "c": 3}, {"b": 20, "c": 30, "d": 40}] + want: {} + + - note: intersection_objects_no_overlap + args: [{"a": 1}, {"b": 2}] + want: {} + + - note: intersection_objects_identical + args: [{"x": 1}, {"x": 1}] + want: {"x": 1} + + - note: intersection_objects_partial_value_match + args: [{"a": 1, "b": 2}, {"a": 1, "b": 99}] + want: {"a": 1} diff --git a/tests/azure_policy_builtins/cases/ip_range_contains.yaml b/tests/azure_policy_builtins/cases/ip_range_contains.yaml new file mode 100644 index 0000000..dd39504 --- /dev/null +++ b/tests/azure_policy_builtins/cases/ip_range_contains.yaml @@ -0,0 +1,65 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# Tests for azure.policy.fn.ip_range_contains +builtin: azure.policy.fn.ip_range_contains + +cases: + # ── IPv4 ──────────────────────────────────────────────────────────── + - note: ipv4_contains_ip_true + args: ["10.0.0.0/8", "10.1.2.3"] + want: true + + - note: ipv4_contains_ip_false + args: ["10.0.0.0/8", "192.168.1.1"] + want: false + + - note: ipv4_contains_subnet_true + args: ["10.0.0.0/8", "10.0.0.0/16"] + want: true + + - note: ipv4_contains_subnet_false + args: ["10.0.0.0/16", "10.0.0.0/8"] + want: false + + - note: ipv4_exact_match + args: ["192.168.1.0/24", "192.168.1.100"] + want: true + + - note: ipv4_edge_network_address + args: ["192.168.1.0/24", "192.168.1.0"] + want: true + + - note: ipv4_edge_broadcast + args: ["192.168.1.0/24", "192.168.1.255"] + want: true + + - note: ipv4_just_outside + args: ["192.168.1.0/24", "192.168.2.0"] + want: false + + - note: ipv4_slash_32 + args: ["10.0.0.1/32", "10.0.0.1"] + want: true + + - note: ipv4_slash_32_miss + args: ["10.0.0.1/32", "10.0.0.2"] + want: false + + # ── IPv6 ──────────────────────────────────────────────────────────── + - note: ipv6_contains_ip_true + args: ["fd00::/8", "fd12:3456::1"] + want: true + + - note: ipv6_contains_ip_false + args: ["fd00::/8", "2001:db8::1"] + want: false + + # ── Invalid inputs ────────────────────────────────────────────────── + - note: ip_invalid_range + args: ["not-a-cidr", "10.0.0.1"] + want: false + + - note: ip_invalid_target + args: ["10.0.0.0/8", "not-an-ip"] + want: false diff --git a/tests/azure_policy_builtins/cases/items.yaml b/tests/azure_policy_builtins/cases/items.yaml new file mode 100644 index 0000000..e7ee89d --- /dev/null +++ b/tests/azure_policy_builtins/cases/items.yaml @@ -0,0 +1,40 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# Tests for azure.policy.fn.items +builtin: azure.policy.fn.items + +cases: + - note: items_simple_object + args: + - name: "test" + value: 42 + want: + - key: "name" + value: "test" + - key: "value" + value: 42 + + - note: items_single_key + args: + - a: 1 + want: + - key: "a" + value: 1 + + - note: items_nested_value + args: + - x: + inner: true + want: + - key: "x" + value: + inner: true + + - note: items_non_object + args: ["not_an_object"] + want_undefined: true + + - note: items_number_arg + args: [42] + want_undefined: true diff --git a/tests/azure_policy_builtins/cases/join.yaml b/tests/azure_policy_builtins/cases/join.yaml new file mode 100644 index 0000000..76ab29b --- /dev/null +++ b/tests/azure_policy_builtins/cases/join.yaml @@ -0,0 +1,42 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# Tests for azure.policy.fn.join +builtin: azure.policy.fn.join + +cases: + - note: join_strings_comma + args: [["a", "b", "c"], ","] + want: "a,b,c" + + - note: join_strings_space + args: [["hello", "world"], " "] + want: "hello world" + + - note: join_single_element + args: [["only"], ","] + want: "only" + + - note: join_empty_array + args: [[], ","] + want: "" + + - note: join_empty_delimiter + args: [["a", "b", "c"], ""] + want: "abc" + + - note: join_with_numbers + args: [[1, 2, 3], "-"] + want: "1-2-3" + + - note: join_with_booleans + args: [[true, false], "|"] + want: "true|false" + + - note: join_with_null + args: [["a", null, "b"], ","] + want: "a,null,b" + + - note: join_multichar_delimiter + args: [["x", "y", "z"], " :: "] + want: "x :: y :: z" diff --git a/tests/azure_policy_builtins/cases/json.yaml b/tests/azure_policy_builtins/cases/json.yaml new file mode 100644 index 0000000..d570589 --- /dev/null +++ b/tests/azure_policy_builtins/cases/json.yaml @@ -0,0 +1,51 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# Tests for azure.policy.fn.json +builtin: azure.policy.fn.json + +cases: + - note: parse_object + args: ['{"name":"test","value":42}'] + want: + name: test + value: 42 + + - note: parse_array + args: ['[1,2,3]'] + want: [1, 2, 3] + + - note: parse_string + args: ['"hello"'] + want: "hello" + + - note: parse_number + args: ["42"] + want: 42 + + - note: parse_boolean_true + args: ["true"] + want: true + + - note: parse_boolean_false + args: ["false"] + want: false + + - note: parse_null + args: ["null"] + want_null: true + + - note: parse_nested_object + args: ['{"a":{"b":{"c":1}}}'] + want: + a: + b: + c: 1 + + - note: invalid_json + args: ["{not valid json}"] + want_error: "json()" + + - note: non_string_arg + args: [42] + want_undefined: true diff --git a/tests/azure_policy_builtins/cases/last.yaml b/tests/azure_policy_builtins/cases/last.yaml new file mode 100644 index 0000000..31f89fe --- /dev/null +++ b/tests/azure_policy_builtins/cases/last.yaml @@ -0,0 +1,40 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# Tests for azure.policy.fn.last +builtin: azure.policy.fn.last + +cases: + # ── Arrays ────────────────────────────────────────────────────────── + - note: last_array_single + args: [[42]] + want: 42 + + - note: last_array_multiple + args: [[10, 20, 30]] + want: 30 + + - note: last_array_strings + args: [["alpha", "beta", "gamma"]] + want: "gamma" + + - note: last_array_empty + args: [[]] + want_null: true + + # ── Strings ───────────────────────────────────────────────────────── + - note: last_string + args: ["hello"] + want: "o" + + - note: last_string_single_char + args: ["x"] + want: "x" + + - note: last_string_empty + args: [""] + want: "" + + - note: last_string_unicode + args: ["café"] + want: "é" diff --git a/tests/azure_policy_builtins/cases/last_index_of.yaml b/tests/azure_policy_builtins/cases/last_index_of.yaml new file mode 100644 index 0000000..f7fb862 --- /dev/null +++ b/tests/azure_policy_builtins/cases/last_index_of.yaml @@ -0,0 +1,34 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# Tests for azure.policy.fn.last_index_of +builtin: azure.policy.fn.last_index_of + +cases: + - note: last_index_of_found + args: ["hello world hello", "hello"] + want: 12 + + - note: last_index_of_single_occurrence + args: ["hello world", "world"] + want: 6 + + - note: last_index_of_not_found + args: ["hello", "xyz"] + want: -1 + + - note: last_index_of_empty_needle + args: ["hello", ""] + want: 5 + + - note: last_index_of_repeated + args: ["abcabcabc", "abc"] + want: 6 + + - note: last_index_of_single_char + args: ["abcabc", "c"] + want: 5 + + - note: last_index_of_case_insensitive + args: ["Hello HELLO hello", "hello"] + want: 12 diff --git a/tests/azure_policy_builtins/cases/logic_all.yaml b/tests/azure_policy_builtins/cases/logic_all.yaml new file mode 100644 index 0000000..67ef3d9 --- /dev/null +++ b/tests/azure_policy_builtins/cases/logic_all.yaml @@ -0,0 +1,38 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# Tests for azure.policy.logic_all (ARM template and() function) +builtin: azure.policy.logic_all + +cases: + - note: all_true + args: [true, true, true] + want: true + + - note: one_false + args: [true, false, true] + want: false + + - note: all_false + args: [false, false, false] + want: false + + - note: single_true + args: [true] + want: true + + - note: single_false + args: [false] + want: false + + - note: two_args_both_true + args: [true, true] + want: true + + - note: two_args_one_false + args: [true, false] + want: false + + - note: empty_args_vacuously_true + args: [] + want: true diff --git a/tests/azure_policy_builtins/cases/logic_any.yaml b/tests/azure_policy_builtins/cases/logic_any.yaml new file mode 100644 index 0000000..2b47f2c --- /dev/null +++ b/tests/azure_policy_builtins/cases/logic_any.yaml @@ -0,0 +1,34 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# Tests for azure.policy.logic_any (ARM template or() function) +builtin: azure.policy.logic_any + +cases: + - note: all_true + args: [true, true, true] + want: true + + - note: one_true + args: [false, true, false] + want: true + + - note: all_false + args: [false, false, false] + want: false + + - note: single_true + args: [true] + want: true + + - note: single_false + args: [false] + want: false + + - note: two_args_one_true + args: [false, true] + want: true + + - note: empty_args_vacuously_false + args: [] + want: false diff --git a/tests/azure_policy_builtins/cases/max.yaml b/tests/azure_policy_builtins/cases/max.yaml new file mode 100644 index 0000000..144d9af --- /dev/null +++ b/tests/azure_policy_builtins/cases/max.yaml @@ -0,0 +1,42 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# Tests for azure.policy.fn.max +builtin: azure.policy.fn.max + +cases: + - note: max_two_args + args: [3, 1] + want: 3 + + - note: max_three_args + args: [5, 2, 8] + want: 8 + + - note: max_equal_args + args: [3, 3, 3] + want: 3 + + - note: max_negative + args: [-5, 0, 5] + want: 5 + + - note: max_single_arg + args: [42] + want: 42 + + - note: max_array + args: [[3, 1, 4, 1, 5]] + want: 5 + + - note: max_array_single + args: [[99]] + want: 99 + + - note: max_array_negatives + args: [[-10, -20, -5]] + want: -5 + + - note: max_array_empty + args: [[]] + want_undefined: true diff --git a/tests/azure_policy_builtins/cases/min.yaml b/tests/azure_policy_builtins/cases/min.yaml new file mode 100644 index 0000000..30905a9 --- /dev/null +++ b/tests/azure_policy_builtins/cases/min.yaml @@ -0,0 +1,44 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# Tests for azure.policy.fn.min +builtin: azure.policy.fn.min + +cases: + # ── Multiple arguments ────────────────────────────────────────────── + - note: min_two_args + args: [3, 1] + want: 1 + + - note: min_three_args + args: [5, 2, 8] + want: 2 + + - note: min_equal_args + args: [3, 3, 3] + want: 3 + + - note: min_negative + args: [-5, 0, 5] + want: -5 + + - note: min_single_arg + args: [42] + want: 42 + + # ── Array argument ────────────────────────────────────────────────── + - note: min_array + args: [[3, 1, 4, 1, 5]] + want: 1 + + - note: min_array_single + args: [[99]] + want: 99 + + - note: min_array_negatives + args: [[-10, -20, -5]] + want: -20 + + - note: min_array_empty + args: [[]] + want_undefined: true diff --git a/tests/azure_policy_builtins/cases/pad_left.yaml b/tests/azure_policy_builtins/cases/pad_left.yaml new file mode 100644 index 0000000..c09711b --- /dev/null +++ b/tests/azure_policy_builtins/cases/pad_left.yaml @@ -0,0 +1,38 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# Tests for azure.policy.fn.pad_left +builtin: azure.policy.fn.pad_left + +cases: + - note: pad_left_basic + args: ["42", 5, "0"] + want: "00042" + + - note: pad_left_no_padding_needed + args: ["hello", 3, "x"] + want: "hello" + + - note: pad_left_exact_width + args: ["abc", 3, "x"] + want: "abc" + + - note: pad_left_default_space + args: ["42", 5, " "] + want: " 42" + + - note: pad_left_single_char + args: ["x", 5, "-"] + want: "----x" + + - note: pad_left_empty_string + args: ["", 3, "0"] + want: "000" + + - note: pad_left_width_1 + args: ["abc", 1, "0"] + want: "abc" + + - note: pad_left_width_0 + args: ["abc", 0, "0"] + want: "abc" diff --git a/tests/azure_policy_builtins/cases/range.yaml b/tests/azure_policy_builtins/cases/range.yaml new file mode 100644 index 0000000..96581e1 --- /dev/null +++ b/tests/azure_policy_builtins/cases/range.yaml @@ -0,0 +1,38 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# Tests for azure.policy.fn.range +builtin: azure.policy.fn.range + +cases: + - note: range_basic + args: [0, 5] + want: [0, 1, 2, 3, 4] + + - note: range_from_offset + args: [3, 4] + want: [3, 4, 5, 6] + + - note: range_single + args: [0, 1] + want: [0] + + - note: range_zero_count + args: [5, 0] + want: [] + + - note: range_negative_start + args: [-2, 4] + want: [-2, -1, 0, 1] + + - note: range_large_start + args: [100, 3] + want: [100, 101, 102] + + - note: range_count_exceeds_10000 + args: [0, 10001] + want_error: "exceeds maximum of 10000" + + - note: range_sum_exceeds_i32_max + args: [2147483640, 10] + want_error: "exceeds maximum of 2147483647" diff --git a/tests/azure_policy_builtins/cases/resolve_field.yaml b/tests/azure_policy_builtins/cases/resolve_field.yaml new file mode 100644 index 0000000..8b9e418 --- /dev/null +++ b/tests/azure_policy_builtins/cases/resolve_field.yaml @@ -0,0 +1,49 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# Tests for azure.policy.resolve_field +builtin: azure.policy.resolve_field + +cases: + - note: simple_field + args: + - name: "myResource" + type: "Microsoft.Compute/virtualMachines" + - "name" + want: "myResource" + + - note: nested_field_with_dot_path + args: + - properties: + securityRules: + enabled: true + - "properties.securityRules.enabled" + want: true + + - note: missing_field + args: + - name: "myResource" + - "nonExistent" + want_undefined: true + + - note: deeply_nested + args: + - a: + b: + c: + d: "deep" + - "a.b.c.d" + want: "deep" + + - note: top_level_type + args: + - type: "Microsoft.Storage/storageAccounts" + kind: "StorageV2" + - "type" + want: "Microsoft.Storage/storageAccounts" + + - note: non_string_path + args: + - name: "test" + - 42 + want_undefined: true diff --git a/tests/azure_policy_builtins/cases/skip.yaml b/tests/azure_policy_builtins/cases/skip.yaml new file mode 100644 index 0000000..750bf54 --- /dev/null +++ b/tests/azure_policy_builtins/cases/skip.yaml @@ -0,0 +1,48 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# Tests for azure.policy.fn.skip +builtin: azure.policy.fn.skip + +cases: + # ── Arrays ────────────────────────────────────────────────────────── + - note: skip_array_basic + args: [[1, 2, 3, 4, 5], 2] + want: [3, 4, 5] + + - note: skip_array_zero + args: [[1, 2, 3], 0] + want: [1, 2, 3] + + - note: skip_array_all + args: [[1, 2, 3], 3] + want: [] + + - note: skip_array_more_than_length + args: [[1, 2], 5] + want: [] + + - note: skip_array_one + args: [[10, 20, 30], 1] + want: [20, 30] + + - note: skip_array_empty + args: [[], 3] + want: [] + + # ── Strings ───────────────────────────────────────────────────────── + - note: skip_string_basic + args: ["hello world", 6] + want: "world" + + - note: skip_string_zero + args: ["hello", 0] + want: "hello" + + - note: skip_string_all + args: ["hello", 5] + want: "" + + - note: skip_string_more_than_length + args: ["hi", 10] + want: "" diff --git a/tests/azure_policy_builtins/cases/split.yaml b/tests/azure_policy_builtins/cases/split.yaml new file mode 100644 index 0000000..10e1ad0 --- /dev/null +++ b/tests/azure_policy_builtins/cases/split.yaml @@ -0,0 +1,61 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# Tests for azure.policy.fn.split +builtin: azure.policy.fn.split + +cases: + # ── Basic splitting ───────────────────────────────────────────────── + - note: split_simple_comma + args: ["hello,world", ","] + want: ["hello", "world"] + + - note: split_multiple_segments + args: ["a.b.c.d", "."] + want: ["a", "b", "c", "d"] + + - note: split_no_match + args: ["hello", ","] + want: ["hello"] + + - note: split_empty_string + args: ["", ","] + want: [""] + + - note: split_delimiter_at_edges + args: [",hello,", ","] + want: ["", "hello", ""] + + - note: split_consecutive_delimiters + args: ["a,,b", ","] + want: ["a", "", "b"] + + - note: split_multi_char_delimiter + args: ["a::b::c", "::"] + want: ["a", "b", "c"] + + - note: split_single_char_string + args: ["x", "x"] + want: ["", ""] + + # ── Array of delimiters ──────────────────────────────────────────── + - note: split_array_delimiters + args: ["a.b/c", [".", "/"]] + want: ["a", "b", "c"] + + - note: split_array_single_delimiter + args: ["a-b-c", ["-"]] + want: ["a", "b", "c"] + + # ── Edge cases ────────────────────────────────────────────────────── + - note: split_unicode + args: ["café☕bar", "☕"] + want: ["café", "bar"] + + - note: split_entire_string_is_delimiter + args: ["abc", "abc"] + want: ["", ""] + + - note: split_empty_delimiter + args: ["abc", ""] + want: ["abc"] diff --git a/tests/azure_policy_builtins/cases/starts_with.yaml b/tests/azure_policy_builtins/cases/starts_with.yaml new file mode 100644 index 0000000..4a1794e --- /dev/null +++ b/tests/azure_policy_builtins/cases/starts_with.yaml @@ -0,0 +1,38 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# Tests for azure.policy.fn.starts_with +builtin: azure.policy.fn.starts_with + +cases: + - note: starts_with_true + args: ["hello world", "hello"] + want: true + + - note: starts_with_false + args: ["hello world", "world"] + want: false + + - note: starts_with_case_insensitive + args: ["Hello World", "hello"] + want: true + + - note: starts_with_empty_needle + args: ["hello", ""] + want: true + + - note: starts_with_empty_haystack + args: ["", "hello"] + want: false + + - note: starts_with_both_empty + args: ["", ""] + want: true + + - note: starts_with_exact_match + args: ["hello", "hello"] + want: true + + - note: starts_with_longer_needle + args: ["hi", "hello"] + want: false diff --git a/tests/azure_policy_builtins/cases/string.yaml b/tests/azure_policy_builtins/cases/string.yaml new file mode 100644 index 0000000..012f337 --- /dev/null +++ b/tests/azure_policy_builtins/cases/string.yaml @@ -0,0 +1,38 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# Tests for azure.policy.fn.string +builtin: azure.policy.fn.string + +cases: + - note: string_from_string + args: ["hello"] + want: "hello" + + - note: string_from_integer + args: [42] + want: "42" + + - note: string_from_negative + args: [-7] + want: "-7" + + - note: string_from_bool_true + args: [true] + want: "true" + + - note: string_from_bool_false + args: [false] + want: "false" + + - note: string_from_null + args: [null] + want: "null" + + - note: string_from_zero + args: [0] + want: "0" + + - note: string_from_empty + args: [""] + want: "" diff --git a/tests/azure_policy_builtins/cases/take.yaml b/tests/azure_policy_builtins/cases/take.yaml new file mode 100644 index 0000000..30c6d7c --- /dev/null +++ b/tests/azure_policy_builtins/cases/take.yaml @@ -0,0 +1,48 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# Tests for azure.policy.fn.take +builtin: azure.policy.fn.take + +cases: + # ── Arrays ────────────────────────────────────────────────────────── + - note: take_array_basic + args: [[1, 2, 3, 4, 5], 3] + want: [1, 2, 3] + + - note: take_array_zero + args: [[1, 2, 3], 0] + want: [] + + - note: take_array_more_than_length + args: [[1, 2], 5] + want: [1, 2] + + - note: take_array_all + args: [[1, 2, 3], 3] + want: [1, 2, 3] + + - note: take_array_one + args: [[10, 20, 30], 1] + want: [10] + + - note: take_array_empty + args: [[], 3] + want: [] + + # ── Strings ───────────────────────────────────────────────────────── + - note: take_string_basic + args: ["hello world", 5] + want: "hello" + + - note: take_string_zero + args: ["hello", 0] + want: "" + + - note: take_string_more_than_length + args: ["hi", 10] + want: "hi" + + - note: take_string_one + args: ["hello", 1] + want: "h" diff --git a/tests/azure_policy_builtins/cases/trim.yaml b/tests/azure_policy_builtins/cases/trim.yaml new file mode 100644 index 0000000..09e4f00 --- /dev/null +++ b/tests/azure_policy_builtins/cases/trim.yaml @@ -0,0 +1,38 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# Tests for azure.policy.fn.trim +builtin: azure.policy.fn.trim + +cases: + - note: trim_whitespace + args: [" hello "] + want: "hello" + + - note: trim_tabs_and_newlines + args: ["\thello\n"] + want: "hello" + + - note: trim_no_whitespace + args: ["hello"] + want: "hello" + + - note: trim_empty + args: [""] + want: "" + + - note: trim_only_whitespace + args: [" "] + want: "" + + - note: trim_left_only + args: [" hello"] + want: "hello" + + - note: trim_right_only + args: ["hello "] + want: "hello" + + - note: trim_inner_whitespace_preserved + args: [" hello world "] + want: "hello world" diff --git a/tests/azure_policy_builtins/cases/try_get.yaml b/tests/azure_policy_builtins/cases/try_get.yaml new file mode 100644 index 0000000..fd940ff --- /dev/null +++ b/tests/azure_policy_builtins/cases/try_get.yaml @@ -0,0 +1,51 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# Tests for azure.policy.fn.try_get +builtin: azure.policy.fn.try_get + +cases: + - note: get_existing_key + args: + - name: "test" + value: 42 + - "name" + want: "test" + + - note: get_missing_key + args: + - name: "test" + - "missing" + want_null: true + + - note: get_array_index + args: [["a", "b", "c"], 1] + want: "b" + + - note: get_array_out_of_bounds + args: [["a", "b"], 5] + want_null: true + + - note: get_from_non_container + args: ["not_an_object", "key"] + want_null: true + + - note: get_null_item + args: [null, "key"] + want_null: true + + - note: get_nested_value + args: + - data: + inner: true + - "data" + want: + inner: true + + - note: get_number_item + args: [42, "key"] + want_null: true + + - note: get_array_first + args: [[10, 20, 30], 0] + want: 10 diff --git a/tests/azure_policy_builtins/cases/try_index_from_end.yaml b/tests/azure_policy_builtins/cases/try_index_from_end.yaml new file mode 100644 index 0000000..1b69715 --- /dev/null +++ b/tests/azure_policy_builtins/cases/try_index_from_end.yaml @@ -0,0 +1,38 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# Tests for azure.policy.fn.try_index_from_end +builtin: azure.policy.fn.try_index_from_end + +cases: + - note: last_element + args: [["a", "b", "c", "d"], 1] + want: "d" + + - note: second_from_end + args: [["a", "b", "c", "d"], 2] + want: "c" + + - note: first_element_via_end + args: [["a", "b", "c", "d"], 4] + want: "a" + + - note: index_zero_returns_null + args: [["a", "b"], 0] + want_null: true + + - note: index_exceeds_length_returns_null + args: [["a", "b"], 5] + want_null: true + + - note: non_array_returns_null + args: ["not_an_array", 1] + want_null: true + + - note: single_element + args: [["only"], 1] + want: "only" + + - note: numeric_array + args: [[10, 20, 30], 2] + want: 20 diff --git a/tests/azure_policy_builtins/cases/union.yaml b/tests/azure_policy_builtins/cases/union.yaml new file mode 100644 index 0000000..590cd8e --- /dev/null +++ b/tests/azure_policy_builtins/cases/union.yaml @@ -0,0 +1,89 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# Tests for azure.policy.fn.union +builtin: azure.policy.fn.union + +cases: + # ── Array union ───────────────────────────────────────────────────── + - note: union_arrays_basic + args: [[1, 2], [2, 3]] + want: [1, 2, 3] + + - note: union_arrays_no_overlap + args: [[1, 2], [3, 4]] + want: [1, 2, 3, 4] + + - note: union_arrays_identical + args: [[1, 2], [1, 2]] + want: [1, 2] + + - note: union_arrays_empty + args: [[], [1, 2]] + want: [1, 2] + + - note: union_arrays_both_empty + args: [[], []] + want: [] + + - note: union_arrays_three + args: [[1], [2], [3]] + want: [1, 2, 3] + + - note: union_arrays_strings + args: [["a", "b"], ["b", "c"]] + want: ["a", "b", "c"] + + # ── Object union ──────────────────────────────────────────────────── + - note: union_objects_merge + args: [{"a": 1}, {"b": 2}] + want: {"a": 1, "b": 2} + + - note: union_objects_overwrite + args: [{"a": 1, "b": 2}, {"b": 20, "c": 30}] + want: {"a": 1, "b": 20, "c": 30} + + - note: union_objects_empty + args: [{}, {"a": 1}] + want: {"a": 1} + + - note: union_objects_recursive_merge + args: + - outer: + inner_a: 1 + inner_b: 2 + - outer: + inner_b: 20 + inner_c: 30 + want: + outer: + inner_a: 1 + inner_b: 20 + inner_c: 30 + + - note: union_objects_deeply_nested_merge + args: + - level1: + level2: + a: 1 + b: 2 + - level1: + level2: + b: 20 + c: 30 + want: + level1: + level2: + a: 1 + b: 20 + c: 30 + + - note: union_objects_array_replaces_not_merges + args: + - data: + items: [1, 2, 3] + - data: + items: [4, 5] + want: + data: + items: [4, 5] diff --git a/tests/azure_policy_builtins/cases/uri.yaml b/tests/azure_policy_builtins/cases/uri.yaml new file mode 100644 index 0000000..57965e9 --- /dev/null +++ b/tests/azure_policy_builtins/cases/uri.yaml @@ -0,0 +1,54 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# Tests for azure.policy.fn.uri +builtin: azure.policy.fn.uri + +cases: + - note: base_with_trailing_slash + args: ["https://example.com/", "path/to/resource"] + want: "https://example.com/path/to/resource" + + - note: base_without_trailing_slash + args: ["https://example.com/base", "relative"] + want: "https://example.com/relative" + + - note: relative_is_absolute_url + args: ["https://example.com/base", "https://other.com/path"] + want: "https://other.com/path" + + - note: relative_with_leading_slash + args: ["https://example.com/", "/path"] + want: "https://example.com/path" + + - note: base_with_path_components + args: ["https://example.com/a/b/c", "d"] + want: "https://example.com/a/b/d" + + - note: non_string_base + args: [42, "relative"] + want_undefined: true + + - note: non_string_relative + args: ["https://example.com/", 42] + want_undefined: true + + - note: simple_base + args: ["https://example.com/", "api/v1"] + want: "https://example.com/api/v1" + + - note: authority_only_base + args: ["https://example.com", "api/v1"] + want: "https://example.com/api/v1" + + - note: authority_only_base_with_leading_slash + args: ["https://example.com", "/api/v1"] + want: "https://example.com/api/v1" + + - note: uri_relative_with_query_string + args: ["https://example.com/api", "v2?key=value"] + want: "https://example.com/v2?key=value" + + - note: uri_relative_with_fragment + args: ["https://example.com/docs/", "page#section"] + want: "https://example.com/docs/page#section" diff --git a/tests/azure_policy_builtins/cases/uri_component.yaml b/tests/azure_policy_builtins/cases/uri_component.yaml new file mode 100644 index 0000000..f1e0721 --- /dev/null +++ b/tests/azure_policy_builtins/cases/uri_component.yaml @@ -0,0 +1,30 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# Tests for azure.policy.fn.uri_component and azure.policy.fn.uri_component_to_string +builtin: azure.policy.fn.uri_component + +cases: + - note: uri_encode_simple + args: ["hello world"] + want: "hello%20world" + + - note: uri_encode_special_chars + args: ["a=b&c=d"] + want: "a%3Db%26c%3Dd" + + - note: uri_encode_already_safe + args: ["hello"] + want: "hello" + + - note: uri_encode_empty + args: [""] + want: "" + + - note: uri_encode_slash + args: ["path/to/resource"] + want: "path%2Fto%2Fresource" + + - note: uri_encode_spaces_and_plus + args: ["hello+world 2"] + want: "hello%2Bworld%202" diff --git a/tests/azure_policy_builtins/cases/uri_component_to_string.yaml b/tests/azure_policy_builtins/cases/uri_component_to_string.yaml new file mode 100644 index 0000000..eefcc3a --- /dev/null +++ b/tests/azure_policy_builtins/cases/uri_component_to_string.yaml @@ -0,0 +1,26 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# Tests for azure.policy.fn.uri_component_to_string +builtin: azure.policy.fn.uri_component_to_string + +cases: + - note: uri_decode_simple + args: ["hello%20world"] + want: "hello world" + + - note: uri_decode_special_chars + args: ["a%3Db%26c%3Dd"] + want: "a=b&c=d" + + - note: uri_decode_no_encoding + args: ["hello"] + want: "hello" + + - note: uri_decode_empty + args: [""] + want: "" + + - note: uri_decode_plus_literal + args: ["hello%2Bworld"] + want: "hello+world" diff --git a/tests/azure_policy_builtins/mod.rs b/tests/azure_policy_builtins/mod.rs new file mode 100644 index 0000000..997c7b6 --- /dev/null +++ b/tests/azure_policy_builtins/mod.rs @@ -0,0 +1,215 @@ +// 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, +} + +#[derive(Debug, Deserialize)] +struct TestCase { + /// Short human-readable label. + note: String, + /// Positional arguments fed to the builtin. + args: Vec, + /// Expected return value (`null` for JSON null). + want: Option, + /// 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, + /// 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 = 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("".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 = 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(); +} diff --git a/tests/mod.rs b/tests/mod.rs index 56086b8..74fe92a 100644 --- a/tests/mod.rs +++ b/tests/mod.rs @@ -1,6 +1,9 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +#[cfg(feature = "azure_policy")] +mod azure_policy_builtins; + #[cfg(feature = "coverage")] mod coverage;