Skip to content

Commit 5b60daa

Browse files
authored
feat: add Azure Policy builtins with YAML test suite (microsoft#630)
* feat: add Azure Policy builtins with YAML test suite Implement ARM template functions for Azure Policy evaluation: Builtins: - String: indexOf, lastIndexOf, trim, format, split, startsWith, endsWith, padLeft, concat, replace, toLower, toUpper, substring, guid, uniqueString - DateTime: dateTimeAdd, dateTimeFromEpoch, dateTimeToEpoch, addDays - Collection: intersection, union, take, skip, first, last, min, max, range, items, tryGet, tryIndexFromEnd, empty, array, createObject - Encoding: base64, base64ToString, base64ToJson, uri, uriComponent, uriComponentToString, dataUri, dataUriToString - Numeric: int, float, intDiv, intMod - Misc: json, join, bool, string, coalesce, if, getParameter, resolveField - Logic: logicAll, logicAny Key implementation details: - Unicode case-insensitive search via ICU4X case folding with single-pass fold_with_char_map() for indexOf/lastIndexOf - .NET composite formatting (System.String.Format) with alignment, standard and custom datetime format specifiers, numeric format specifiers - DateTime round-trip preserves input shape (Z vs +00:00, T vs space, fractional seconds) when no explicit output format is supplied - Zero-cost as_str() helper borrows directly from Value::String(Rc<str>) - BTreeSet<&Value> in array union avoids redundant cloning Test suite: - 53 YAML test files exercising all builtins via direct BUILTINS registry - Coverage for edge cases: empty inputs, Unicode, fractional seconds, invalid alignment, unknown format specifiers, RFC3339 offset shapes Signed-off-by: Anand Krishnamoorthi <anakrish@microsoft.com> * fix: address PR review comments - Fix percent_encode to only uppercase hex digits, not entire string - Remove guid/uniqueString (unsupported); delete custom SHA-1 impl - Replace unwrap_or(0) with proper error in format placeholder parsing - Hoist CaseMapper into static CaseMapperBorrowed for zero per-call overhead - Pre-allocate Vec in range() with_capacity - Update bindings/ffi and bindings/ruby Cargo.lock - Fix uri_component test expectations for correct case preservation Signed-off-by: Anand Krishnamoorthi <anakrish@microsoft.com> * fix: address second round of PR review comments - float(): return Undefined when as_f64() fails instead of leaking the original non-f64 representation - createObject(): reject odd number of arguments with an error (ARM-template parity) - format(): error on unknown numeric format specifiers instead of silently passing through (matches .NET FormatException behavior) - format(): cap alignment width at 10,000 to prevent DoS from user-controlled format strings like {0,1000000000} - Add YAML test cases for all new error behaviors Signed-off-by: Anand Krishnamoorthi <anakrish@microsoft.com> * fix: address third round of PR review comments - percent_decode: reject incomplete % escapes (e.g. "%", "%2") instead of treating them as literal characters - parse_iso8601_duration: reject leftover digits without a unit designator at T boundary and end-of-input (e.g. "P1", "P1T2H") - yaml_to_value: panic on unsupported YAML numeric representations instead of silently mapping to Null - Revert unused src/languages/mod.rs changes (module is defined inline in lib.rs) Signed-off-by: Anand Krishnamoorthi <anakrish@microsoft.com> * fix: add missing edge-case tests and fix empty-delimiter panic - fn_split: return input as single-element array for empty string delimiter instead of panicking (Rust's str::split("") panics) - format: add test for F3 higher precision ({0:F3} + 1.23456 → 1.235) - format: add test for N2 float with thousands separator - format: add test for negative index error ({-1}) - split: add test for empty-string delimiter - uri: add tests for query string and fragment in relative URI - createObject: add test for non-string (numeric) keys Signed-off-by: Anand Krishnamoorthi <anakrish@microsoft.com> * fix: address fourth round of PR review comments - Add MAX_VARIADIC_ARGS (64) constant for variadic builtin arity instead of registering with 0 (logic_all, logic_any, min, max, format, intersection, union, coalesce, createObject); set dateTimeAdd to exact arity 3 - Switch indexOf/lastIndexOf to UTF-16 code-unit indices to match .NET String.IndexOf semantics (track ch.len_utf16() in fold_with_char_map, use encode_utf16().count() for empty-needle lastIndexOf) - Use DateTime::<Utc>::from_timestamp for explicit timezone type - Remove stale docs/azure-policy/casing.md link from module doc - Fix misleading comment in want_error test branch (code bails on Undefined, not accepts it) Signed-off-by: Anand Krishnamoorthi <anakrish@microsoft.com> --------- Signed-off-by: Anand Krishnamoorthi <anakrish@microsoft.com>
1 parent f69974d commit 5b60daa

73 files changed

Lines changed: 5894 additions & 1 deletion

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Cargo.lock

Lines changed: 31 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ default = ["full-opa", "arc", "rvm"]
2424

2525
arc = []
2626
ast = []
27-
azure_policy = ["dep:jsonschema", "arc", "dashmap"]
27+
azure_policy = ["dep:jsonschema", "dep:chrono", "dep:ipnet", "dep:icu_casemap", "arc", "dashmap"]
2828
azure-rbac = ["regex", "time", "net"]
2929
base64 = ["dep:data-encoding"]
3030
base64url = ["dep:data-encoding"]
@@ -117,6 +117,7 @@ jsonschema = { version = "0.30.0", default-features = false, optional = true }
117117
chrono = { version = "0.4.40", optional = true }
118118
chrono-tz = { version = "0.10.1", optional = true }
119119
ipnet = { version = "2.11.0", optional = true, default-features = false }
120+
icu_casemap = { version = "2.1", optional = true, default-features = false, features = ["compiled_data"] }
120121

121122
serde_yaml = {version = "0.9.16", default-features = false, optional = true }
122123
# Specify thread_rng for in order to use random_range

bindings/ffi/Cargo.lock

Lines changed: 31 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

bindings/ruby/Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
//! Shared helpers: type coercion, comparison, pattern matching, and path resolution.
5+
6+
#![deny(
7+
clippy::arithmetic_side_effects,
8+
clippy::expect_used,
9+
clippy::indexing_slicing,
10+
clippy::panic,
11+
clippy::shadow_unrelated,
12+
clippy::unwrap_used,
13+
clippy::missing_const_for_fn,
14+
clippy::option_if_let_else,
15+
clippy::semicolon_if_nothing_returned,
16+
clippy::useless_let_if_seq
17+
)]
18+
19+
use crate::languages::azure_policy::strings;
20+
use crate::value::Value;
21+
22+
use alloc::string::{String, ToString as _};
23+
use alloc::vec::Vec;
24+
25+
// ── Type helpers ──────────────────────────────────────────────────────
26+
27+
pub const fn is_true(value: &Value) -> bool {
28+
matches!(value, Value::Bool(true))
29+
}
30+
31+
pub const fn is_undefined(value: &Value) -> bool {
32+
matches!(value, Value::Undefined)
33+
}
34+
35+
pub fn as_string(value: &Value) -> Option<String> {
36+
match *value {
37+
Value::String(ref s) => Some(s.to_string()),
38+
_ => None,
39+
}
40+
}
41+
42+
/// Borrow the inner string of a `Value::String` without cloning.
43+
pub fn as_str(value: &Value) -> Option<&str> {
44+
match *value {
45+
Value::String(ref s) => Some(s),
46+
_ => None,
47+
}
48+
}
49+
50+
/// Try to parse a string as a number for Azure Policy type coercion.
51+
pub fn try_coerce_to_number(s: &str) -> Option<crate::number::Number> {
52+
use core::str::FromStr as _;
53+
// Try integer first, then float.
54+
i64::from_str(s.trim())
55+
.map(crate::number::Number::from)
56+
.ok()
57+
.or_else(|| {
58+
f64::from_str(s.trim())
59+
.map(crate::number::Number::from)
60+
.ok()
61+
})
62+
}
63+
64+
// ── Path resolution ───────────────────────────────────────────────────
65+
66+
pub fn resolve_path(root: &Value, path: &str) -> Value {
67+
let segments = tokenize_path(path);
68+
let mut current = root.clone();
69+
70+
for segment in segments {
71+
#[allow(clippy::pattern_type_mismatch)]
72+
match &current {
73+
Value::Object(map) => {
74+
let mut next = None;
75+
for (key, value) in map.iter() {
76+
if let Value::String(ref key_str) = *key {
77+
if strings::keys::eq(key_str, &segment) {
78+
next = Some(value.clone());
79+
break;
80+
}
81+
}
82+
}
83+
84+
if let Some(value) = next {
85+
current = value;
86+
} else {
87+
return Value::Undefined;
88+
}
89+
}
90+
Value::Array(items) => {
91+
let Ok(index) = segment.parse::<usize>() else {
92+
return Value::Undefined;
93+
};
94+
95+
let Some(value) = items.get(index) else {
96+
return Value::Undefined;
97+
};
98+
current = value.clone();
99+
}
100+
_ => return Value::Undefined,
101+
}
102+
}
103+
104+
current
105+
}
106+
107+
fn tokenize_path(path: &str) -> Vec<String> {
108+
let mut segments = Vec::new();
109+
let mut token = String::new();
110+
let mut bracket = String::new();
111+
let mut in_bracket = false;
112+
113+
for ch in path.chars() {
114+
match ch {
115+
'.' if !in_bracket => {
116+
if !token.is_empty() {
117+
segments.push(token.clone());
118+
token.clear();
119+
}
120+
}
121+
'[' => {
122+
in_bracket = true;
123+
if !token.is_empty() {
124+
segments.push(token.clone());
125+
token.clear();
126+
}
127+
}
128+
']' => {
129+
in_bracket = false;
130+
let cleaned = bracket.trim_matches('"').trim_matches('\'').to_string();
131+
if !cleaned.is_empty() {
132+
segments.push(cleaned);
133+
}
134+
bracket.clear();
135+
}
136+
_ => {
137+
if in_bracket {
138+
bracket.push(ch);
139+
} else {
140+
token.push(ch);
141+
}
142+
}
143+
}
144+
}
145+
146+
if !token.is_empty() {
147+
segments.push(token);
148+
}
149+
150+
segments
151+
}

0 commit comments

Comments
 (0)