feat(azure-policy): add alias normalization and denormalization (#635)

* feat: add Azure Policy alias normalization/denormalization

Add normalizer and denormalizer for ARM JSON resources, enabling Azure
Policy alias short names to become direct paths into a flat structure.

- Normalizer: flattens properties wrappers, lowercases keys, resolves
  per-alias versioned ARM paths, handles sub-resource array flattening,
  element-level field remaps, and array base renames
- Denormalizer: reverses all transformations with casing restoration
- AliasRegistry: loads production alias catalogs and data policy manifests
- Types: serde deserialization for ARM provider alias formats
- YAML test suite: 13 test files covering normalize, denormalize, round-trip,
  data-plane, edge cases, malformed input, sub-resources, and registry API
- Benchmark suite for normalization performance

* feat: add FFI and C# bindings for alias normalization

- FFI: alias_registry.rs with C-compatible API for loading catalogs,
  normalizing resources, and denormalizing back to ARM JSON
- C#: AliasRegistry wrapper class with NativeMethods P/Invoke bindings
  and integration tests
- Updated Cargo.lock files for new serde_json dependency
This commit is contained in:
Anand Krishnamoorthi
2026-03-30 18:44:36 -05:00
committed by GitHub
parent 35fb5d5953
commit d36f952133
46 changed files with 11265 additions and 316 deletions

View File

@@ -0,0 +1,114 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
//! Key casing restoration from alias metadata.
use alloc::collections::BTreeMap;
use alloc::string::{String, ToString as _};
use alloc::vec::Vec;
use crate::Value;
use super::super::obj_map::{make_array, make_value, new_map, obj_insert, val_str, ROOT_FIELDS};
use super::super::types::ResolvedEntry;
fn insert_default_casing(map: &mut BTreeMap<String, String>) {
for &field in ROOT_FIELDS {
map.insert(field.to_ascii_lowercase(), field.to_string());
}
// Canonical casing for standard nested root-field object members that are
// not described by alias metadata but still need round-trip restoration.
for canonical in [
"principalId",
"tenantId",
"userAssignedIdentities",
"promotionCode",
"createdBy",
"createdByType",
"createdAt",
"lastModifiedBy",
"lastModifiedByType",
"lastModifiedAt",
] {
map.entry(canonical.to_ascii_lowercase())
.or_insert_with(|| canonical.to_string());
}
}
/// Build the default casing map used when alias metadata is unavailable.
pub fn default_casing_map() -> BTreeMap<String, String> {
let mut map = BTreeMap::new();
insert_default_casing(&mut map);
map
}
/// Build a mapping from lowercase key → original-cased key from alias entries.
pub fn build_casing_map(entries: &BTreeMap<String, ResolvedEntry>) -> BTreeMap<String, String> {
let mut map = BTreeMap::new();
insert_default_casing(&mut map);
for entry in entries.values() {
for segment in entry.short_name.split('.') {
let clean = segment.replace("[*]", "");
if !clean.is_empty() {
map.entry(clean.to_ascii_lowercase())
.or_insert_with(|| clean.to_string());
}
}
for segment in entry.default_path.split('.') {
let clean = segment.replace("[*]", "");
if !clean.is_empty() && !clean.eq_ignore_ascii_case("properties") {
map.entry(clean.to_ascii_lowercase())
.or_insert_with(|| clean.to_string());
}
}
// Also include segments from all version-specific ARM paths so
// casing can be restored correctly for versioned aliases.
for (_ver, path) in &entry.versioned_paths {
for segment in path.split('.') {
let clean = segment.replace("[*]", "");
if !clean.is_empty() && !clean.eq_ignore_ascii_case("properties") {
map.entry(clean.to_ascii_lowercase())
.or_insert_with(|| clean.to_string());
}
}
}
}
map
}
/// Restore the original casing of a key using the casing map.
pub fn restore_casing(key: &str, casing_map: &BTreeMap<String, String>) -> String {
casing_map
.get(&key.to_ascii_lowercase())
.cloned()
.unwrap_or_else(|| key.to_string())
}
/// Recursively restore key casing in a JSON value.
pub fn denormalize_value(value: &Value, casing_map: &BTreeMap<String, String>) -> Value {
match value {
Value::Object(obj) => {
let mut result = new_map();
for (k, v) in obj.iter() {
if let Some(key_s) = val_str(k) {
let restored_key = restore_casing(key_s, casing_map);
obj_insert(&mut result, &restored_key, denormalize_value(v, casing_map));
}
}
make_value(result)
}
Value::Array(arr) => {
let items: Vec<Value> = arr
.iter()
.map(|v| denormalize_value(v, casing_map))
.collect();
make_array(items)
}
_ => value.clone(),
}
}

View File

@@ -0,0 +1,13 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
//! Small helper functions used by the denormalizer.
use crate::Rc;
use super::super::obj_map::ObjMap;
/// Find a key in an ObjMap using case-insensitive comparison.
pub fn find_key_ci(obj: &ObjMap, key: &str) -> Option<Rc<str>> {
obj.keys().find(|k| k.eq_ignore_ascii_case(key)).cloned()
}

View File

@@ -0,0 +1,227 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
//! Normalized `input.resource` → ARM JSON reverse transformation.
mod casing;
pub(crate) mod helpers;
mod sub_resource;
#[cfg(test)]
mod tests;
use alloc::collections::BTreeSet;
use crate::Value;
use crate::Rc;
use super::obj_map::{
extract_type_field, is_root_field_collision, make_value, new_map, obj_insert,
set_nested_verbatim, val_str, ROOT_FIELDS,
};
use super::types::ResolvedAliases;
use super::AliasRegistry;
use super::normalizer::{apply_element_remap, ElementRemap};
use casing::{build_casing_map, default_casing_map, denormalize_value, restore_casing};
use helpers::find_key_ci;
use super::obj_map::remove_element_field;
/// Denormalize a normalized resource back to ARM JSON structure.
pub fn denormalize(
normalized: &Value,
registry: Option<&AliasRegistry>,
api_version: Option<&str>,
) -> Value {
let aliases = registry.and_then(|r| extract_type_field(normalized).and_then(|rt| r.get(rt)));
denormalize_with_aliases(normalized, aliases, api_version)
}
/// Internal denormalization with pre-resolved alias data.
pub fn denormalize_with_aliases(
normalized: &Value,
aliases: Option<&ResolvedAliases>,
api_version: Option<&str>,
) -> Value {
let obj = match normalized.as_object() {
Ok(o) => o,
Err(_) => return normalized.clone(),
};
let entries = aliases.map(|a| &a.entries);
let casing_map = entries
.map(build_casing_map)
.unwrap_or_else(default_casing_map);
let empty_set = BTreeSet::new();
let sub_resource_set = aliases.map_or(&empty_set, |a| &a.sub_resource_arrays);
let is_data_plane =
extract_type_field(normalized).is_some_and(|t| t.to_ascii_lowercase().contains(".data/"));
let mut result = new_map();
let mut properties = new_map();
// Phase 1: Root fields → ARM root with original casing.
for &field in ROOT_FIELDS {
let lc = field.to_ascii_lowercase();
// Fast-path: direct BTreeMap lookup (O(log N)) for the common case
// where normalized input was produced by our normalizer with lowercase keys.
// Falls back to linear case-insensitive scan for externally-supplied mixed-case input.
let lc_key = Value::String(Rc::from(lc.as_str()));
let found = obj.get(&lc_key).or_else(|| {
obj.iter()
.find(|(k, _)| val_str(k).is_some_and(|s| s.eq_ignore_ascii_case(&lc)))
.map(|(_, v)| v)
});
if let Some(val) = found {
let restored = denormalize_value(val, &casing_map);
obj_insert(&mut result, field, restored);
}
}
// Phase 2a: Non-aliased, non-root fields.
for (key, val) in obj.iter() {
let key_s = match val_str(key) {
Some(s) => s,
None => continue,
};
if ROOT_FIELDS.iter().any(|f| f.eq_ignore_ascii_case(key_s)) {
continue;
}
let lookup_key = key_s.strip_prefix("_p_").unwrap_or(key_s);
let lookup_key_lc = lookup_key.to_ascii_lowercase();
let has_alias = entries.is_some_and(|e| e.contains_key(lookup_key_lc.as_str()));
if has_alias {
continue;
}
let denorm_val = denormalize_value(val, &casing_map);
if key_s.starts_with("_p_") {
let restored = restore_casing(lookup_key, &casing_map);
obj_insert(&mut properties, &restored, denorm_val);
} else if is_data_plane {
let restored = restore_casing(key_s, &casing_map);
obj_insert(&mut result, &restored, denorm_val);
} else {
let restored = restore_casing(key_s, &casing_map);
obj_insert(&mut properties, &restored, denorm_val);
}
}
// Phase 2b: Aliased scalar fields → versioned ARM paths.
if let Some(entries) = entries {
for (lc_key, entry) in entries {
if entry.is_wildcard {
continue;
}
if sub_resource_set.contains(lc_key.as_str()) {
continue;
}
let normalized_key = if is_root_field_collision(&entry.short_name, &entry.default_path)
{
alloc::format!("_p_{}", entry.short_name.to_ascii_lowercase())
} else {
lc_key.clone()
};
// Fast-path: direct BTreeMap lookup for lowercase keys,
// with case-insensitive fallback for mixed-case external input.
let nk_val = Value::String(Rc::from(normalized_key.as_str()));
let val = obj.get(&nk_val).or_else(|| {
obj.iter()
.find(|(k, _)| {
val_str(k).is_some_and(|s| s.eq_ignore_ascii_case(&normalized_key))
})
.map(|(_, v)| v)
});
let val = match val {
Some(v) => v,
None => continue,
};
let arm_path = entry.select_path(api_version);
let denorm_val = denormalize_value(val, &casing_map);
if let Some(props_path) = arm_path.strip_prefix("properties.") {
set_nested_verbatim(&mut properties, props_path, denorm_val);
} else {
set_nested_verbatim(&mut result, arm_path, denorm_val);
}
}
// Phase 2c + 2d: Use precomputed renames/remaps.
// Look up versioned aggregates when api_version is provided,
// falling back to default aggregates.
if let Some(aliases) = aliases {
let agg = api_version.map_or(&aliases.default_aggregates, |ver| {
let ver_lc = ver.to_ascii_lowercase();
aliases
.versioned_aggregates
.get(&ver_lc)
.unwrap_or(&aliases.default_aggregates)
});
// Phase 2c: Precomputed array base renames.
for (alias_base_lc, arm_base) in &agg.array_renames_denormalize {
if let Some(key) = find_key_ci(&properties, alias_base_lc) {
if let Some(val) = properties.remove(key.as_ref()) {
obj_insert(&mut properties, arm_base, val);
}
}
}
// Phase 2d: Precomputed reverse element-level field remaps.
for rev in &agg.reverse_element_remaps {
let remap = ElementRemap {
array_chain: rev.array_chain.clone(),
source_field: rev.source_field.clone(),
target_field: if rev.target_field.contains('.') {
rev.target_field
.split('.')
.map(|segment| restore_casing(segment, &casing_map))
.collect::<alloc::vec::Vec<_>>()
.join(".")
} else {
restore_casing(&rev.target_field, &casing_map)
},
};
apply_element_remap(&mut properties, &remap, false);
remove_element_field(&mut properties, &rev.array_chain, &rev.cleanup_field);
}
}
}
// Phase 3: Re-wrap sub-resource array elements.
if let Some(aliases) = aliases {
if !aliases.sub_resource_arrays.is_empty() {
sub_resource::rewrap_sub_resource_arrays(
&mut properties,
&aliases.sub_resource_arrays,
&aliases.entries,
api_version,
);
}
}
// Phase 4: Attach properties to result.
if !properties.is_empty() {
if let Some(Value::Object(existing_rc)) = result.get_mut("properties") {
// Merge directly into the BTreeMap, avoiding full ObjMap round-trip.
let existing = Rc::make_mut(existing_rc);
for (k, v) in properties {
existing.entry(Value::String(k)).or_insert(v);
}
} else {
obj_insert(&mut result, "properties", make_value(properties));
}
}
make_value(result)
}

View File

@@ -0,0 +1,228 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
//! Sub-resource array re-wrapping during denormalization.
use alloc::collections::{BTreeMap, BTreeSet};
use alloc::string::String;
use alloc::vec::Vec;
use crate::Value;
use super::super::obj_map::{make_value, new_map, obj_insert, val_str, ObjMap};
use super::super::types::ResolvedEntry;
use super::helpers::find_key_ci;
/// Sub-resource array element envelope fields that remain at the element root.
const ELEMENT_ENVELOPE_FIELDS: &[&str] = &["name", "type", "id", "etag"];
/// Re-wrap sub-resource array elements by moving non-envelope fields back
/// under each element's `properties` object.
pub fn rewrap_sub_resource_arrays(
properties: &mut ObjMap,
sub_arrays: &BTreeSet<String>,
entries: &BTreeMap<String, ResolvedEntry>,
api_version: Option<&str>,
) {
let mut sorted: Vec<&String> = sub_arrays.iter().collect();
sorted.sort_by(|a, b| {
let depth_a = a.chars().filter(|&c| c == '.').count();
let depth_b = b.chars().filter(|&c| c == '.').count();
depth_b.cmp(&depth_a)
});
for sub_array_path in sorted {
let envelope_fields = classify_envelope_fields(sub_array_path, entries, api_version);
let parts: Vec<&str> = sub_array_path.split('.').collect();
if parts.len() == 1 {
if let Some(key) = parts.first().and_then(|p| find_key_ci(properties, p)) {
if let Some(Value::Array(arr)) = properties.get_mut(key.as_ref()) {
let inner = crate::Rc::make_mut(arr);
for elem in inner.iter_mut() {
*elem = rewrap_element(elem, &envelope_fields);
}
}
}
} else if let Some((&array_name, parent_parts)) = parts.split_last() {
rewrap_nested_array(properties, parent_parts, array_name, &envelope_fields);
}
}
}
/// Determine which element-level fields are envelope fields for a given
/// sub-resource array.
fn classify_envelope_fields(
sub_array_path: &str,
entries: &BTreeMap<String, ResolvedEntry>,
api_version: Option<&str>,
) -> BTreeSet<String> {
let mut envelope = BTreeSet::new();
for &f in ELEMENT_ENVELOPE_FIELDS {
envelope.insert(f.to_ascii_lowercase());
}
let wildcard_prefix: String = {
let parts: Vec<&str> = sub_array_path.split('.').collect();
let mut prefix = String::new();
for (i, part) in parts.iter().enumerate() {
if i > 0 {
prefix.push_str("[*].");
}
prefix.push_str(&part.to_ascii_lowercase());
}
prefix.push_str("[*].");
prefix
};
for (lc_key, entry) in entries {
if !lc_key.starts_with(&wildcard_prefix) {
continue;
}
let field = &lc_key[wildcard_prefix.len()..];
let first_segment = field.split('.').next().unwrap_or(field);
let arm_path = entry.select_path(api_version);
let arm_after_last_wildcard = arm_path.rsplit("[*].").next().unwrap_or("");
if !arm_after_last_wildcard.starts_with("properties.") {
envelope.insert(first_segment.to_ascii_lowercase());
}
}
envelope
}
/// Recursively navigate nested arrays and re-wrap elements of the innermost
/// sub-resource array.
fn rewrap_nested_array(
obj: &mut ObjMap,
parent_parts: &[&str],
array_name: &str,
envelope_fields: &BTreeSet<String>,
) {
if parent_parts.is_empty() {
return;
}
let parent_key = match parent_parts.first().and_then(|&p| find_key_ci(obj, p)) {
Some(k) => k,
None => return,
};
let parent_arr = match obj.get_mut(parent_key.as_ref()) {
Some(Value::Array(arr)) => crate::Rc::make_mut(arr),
_ => return,
};
for element in parent_arr.iter_mut() {
if let Value::Object(obj_rc) = element {
let inner_btree = crate::Rc::make_mut(obj_rc);
if parent_parts.len() > 1 {
rewrap_nested_array_in_btree(
inner_btree,
parent_parts.get(1..).unwrap_or_default(),
array_name,
envelope_fields,
);
} else if let Some(arr_key) = find_key_ci_btree(inner_btree, array_name) {
if let Some(Value::Array(arr)) = inner_btree.get_mut(&arr_key) {
let inner = crate::Rc::make_mut(arr);
for inner_elem in inner.iter_mut() {
*inner_elem = rewrap_element(inner_elem, envelope_fields);
}
}
}
}
}
}
/// BTreeMap-native recursion for nested sub-resource array re-wrapping,
/// avoiding ObjMap round-trips on each array element.
fn rewrap_nested_array_in_btree(
btree: &mut alloc::collections::BTreeMap<Value, Value>,
parent_parts: &[&str],
array_name: &str,
envelope_fields: &BTreeSet<String>,
) {
if parent_parts.is_empty() {
return;
}
let parent_key = match parent_parts
.first()
.and_then(|&p| find_key_ci_btree(btree, p))
{
Some(k) => k,
None => return,
};
let parent_arr = match btree.get_mut(&parent_key) {
Some(Value::Array(arr)) => crate::Rc::make_mut(arr),
_ => return,
};
for element in parent_arr.iter_mut() {
if let Value::Object(obj_rc) = element {
let inner_btree = crate::Rc::make_mut(obj_rc);
if parent_parts.len() > 1 {
rewrap_nested_array_in_btree(
inner_btree,
parent_parts.get(1..).unwrap_or_default(),
array_name,
envelope_fields,
);
} else if let Some(arr_key) = find_key_ci_btree(inner_btree, array_name) {
if let Some(Value::Array(arr)) = inner_btree.get_mut(&arr_key) {
let inner = crate::Rc::make_mut(arr);
for inner_elem in inner.iter_mut() {
*inner_elem = rewrap_element(inner_elem, envelope_fields);
}
}
}
}
}
}
/// Find a key in a BTreeMap using case-insensitive comparison.
fn find_key_ci_btree(
btree: &alloc::collections::BTreeMap<Value, Value>,
key: &str,
) -> Option<Value> {
btree
.keys()
.find(|k| val_str(k).is_some_and(|s| s.eq_ignore_ascii_case(key)))
.cloned()
}
/// Re-wrap a single sub-resource array element by moving non-envelope
/// fields back under a `properties` object.
fn rewrap_element(element: &Value, envelope_fields: &BTreeSet<String>) -> Value {
let obj = match element.as_object() {
Ok(o) => o,
Err(_) => return element.clone(),
};
let mut envelope = new_map();
let mut props = new_map();
for (key, val) in obj.iter() {
let key_s = match val_str(key) {
Some(s) => s,
None => continue,
};
if envelope_fields.contains(&key_s.to_ascii_lowercase()) {
obj_insert(&mut envelope, key_s, val.clone());
} else {
obj_insert(&mut props, key_s, val.clone());
}
}
if !props.is_empty() {
obj_insert(&mut envelope, "properties", make_value(props));
}
make_value(envelope)
}

View File

@@ -0,0 +1,55 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
//! Inline denormalizer unit tests.
use alloc::collections::BTreeMap;
use alloc::string::ToString as _;
use alloc::vec;
use alloc::vec::Vec;
use super::super::types::ResolvedEntry;
use super::casing::build_casing_map;
fn make_entry(short: &str, default: &str, versioned: Vec<(&str, &str)>) -> ResolvedEntry {
ResolvedEntry::new(
short.to_string(),
default.to_string(),
versioned
.into_iter()
.map(|(v, p)| (v.to_string(), p.to_string()))
.collect(),
None,
)
}
#[test]
fn build_casing_map_extracts_from_aliases() {
let mut entries = BTreeMap::new();
entries.insert(
"supportshttpstrafficonly".to_string(),
make_entry(
"supportsHttpsTrafficOnly",
"properties.supportsHttpsTrafficOnly",
vec![],
),
);
entries.insert(
"networkacls.defaultaction".to_string(),
make_entry(
"networkAcls.defaultAction",
"properties.networkAcls.defaultAction",
vec![],
),
);
let map = build_casing_map(&entries);
assert_eq!(
map.get("supportshttpstrafficonly"),
Some(&"supportsHttpsTrafficOnly".to_string())
);
assert_eq!(map.get("networkacls"), Some(&"networkAcls".to_string()));
assert_eq!(map.get("defaultaction"), Some(&"defaultAction".to_string()));
assert_eq!(map.get("managedby"), Some(&"managedBy".to_string()));
assert_eq!(map.get("apiversion"), Some(&"apiVersion".to_string()));
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,96 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
//! Per-alias path resolution: reads values from versioned ARM paths and places
//! them at alias short name paths in the normalized output.
use alloc::string::String;
use crate::Value;
use super::super::obj_map::remove_element_field;
use super::super::obj_map::{
collision_safe_key, is_root_field_collision, obj_contains, obj_insert, obj_remove,
set_nested_lowercased, ObjMap,
};
use super::super::types::ResolvedAliases;
use super::element_remap::apply_element_remap_precomputed;
use super::flatten::normalize_value;
/// Apply per-alias path resolution to the normalized result.
///
/// Uses precomputed element remaps and array renames from [`ResolvedAliases`]
/// when `api_version` is `None` (the common case). Falls back to dynamic
/// computation when a specific `api_version` is provided.
pub fn apply_alias_entries(
result: &mut ObjMap,
raw: &Value,
aliases: &ResolvedAliases,
api_version: Option<&str>,
) {
let entries = &aliases.entries;
let sub_resource_set = &aliases.sub_resource_arrays;
for (lc_key, entry) in entries {
if entry.is_wildcard {
continue;
}
// Skip sub-resource array root entries.
if sub_resource_set.contains(lc_key.as_str()) {
continue;
}
// Use precomputed segments for all paths (default and versioned).
let segments = entry.select_path_segments(api_version);
let value = navigate_arm_path_segments(raw, segments);
if let Some(value) = value {
let value = normalize_value(&value, &entry.short_name, None);
let target = if is_root_field_collision(&entry.short_name, &entry.default_path) {
collision_safe_key(&entry.short_name)
} else {
entry.short_name.clone()
};
set_nested_lowercased(result, &target, value);
}
}
// Look up precomputed aggregates: default or per-version.
let agg = api_version.map_or(&aliases.default_aggregates, |ver| {
let ver_lc = ver.to_ascii_lowercase();
aliases
.versioned_aggregates
.get(&ver_lc)
.unwrap_or(&aliases.default_aggregates)
});
for remap in &agg.element_remaps {
apply_element_remap_precomputed(result, remap);
// Remove the original ARM field so the normalized output only has the
// alias short name. Without this, the stale source key survives and
// casing restoration during denormalization can produce a duplicate.
remove_element_field(result, &remap.array_chain, &remap.source_field);
}
for (source_lc, target_lc) in &agg.array_renames_normalize {
if !obj_contains(result, target_lc.as_str()) {
if let Some(val) = obj_remove(result, source_lc.as_str()) {
obj_insert(result, target_lc, val);
}
}
}
}
/// Navigate an ARM path using precomputed segments (avoids per-call split).
fn navigate_arm_path_segments(value: &Value, segments: &[String]) -> Option<Value> {
let mut current = value;
for segment in segments {
current = current
.as_object()
.ok()?
.get(&Value::from(segment.as_str()))?;
}
Some(current.clone())
}

View File

@@ -0,0 +1,252 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
//! Element-level field remapping for array aliases with versioned paths.
use alloc::string::String;
use alloc::vec::Vec;
use crate::Value;
use super::super::obj_map::{
obj_get, obj_get_mut, obj_insert, set_nested_in_btree, set_nested_lowercased,
set_nested_verbatim, ObjMap,
};
use super::super::types::PrecomputedRemap;
/// Describes a field remapping inside each element of a (possibly nested) array.
pub struct ElementRemap {
/// Chain of array navigations for nested `[*]` levels.
pub(crate) array_chain: Vec<Vec<String>>,
/// Dot-separated path to read within the innermost array element.
pub(crate) source_field: String,
/// Dot-separated path to write within the innermost array element.
pub(crate) target_field: String,
}
/// Apply an element-level field remap to each element of an array (or nested
/// array chain).
///
/// When `lowercase` is `true` (normalizer), target path segments are
/// lowercased. When `false` (denormalizer), they are written verbatim
/// so that restored casing is preserved.
pub fn apply_element_remap(result: &mut ObjMap, remap: &ElementRemap, lowercase: bool) {
apply_remap_at_depth(
result,
&remap.array_chain,
0,
&remap.source_field,
&remap.target_field,
lowercase,
);
}
/// Apply a precomputed element remap (from [`PrecomputedRemap`]) without
/// any per-call string splitting or allocation.
pub fn apply_element_remap_precomputed(result: &mut ObjMap, remap: &PrecomputedRemap) {
apply_remap_at_depth(
result,
&remap.array_chain,
0,
&remap.source_field,
&remap.target_field,
true,
);
}
/// Recursively navigate nested arrays via `array_chain` and apply a field
/// remap in each innermost element.
fn apply_remap_at_depth(
obj: &mut ObjMap,
array_chain: &[Vec<String>],
depth: usize,
source_field: &str,
target_field: &str,
lowercase: bool,
) {
let Some(nav) = array_chain.get(depth) else {
remap_deep_field(obj, source_field, target_field, lowercase);
return;
};
let first = match nav.first() {
Some(f) => f.as_str(),
None => return,
};
// Navigate through intermediate segments to reach the array value.
let arr_val = if nav.len() == 1 {
match obj_get_mut(obj, first) {
Some(v) => v,
None => return,
}
} else {
let mut cur: &mut Value = match obj_get_mut(obj, first) {
Some(v) => v,
None => return,
};
for segment in nav.iter().skip(1) {
cur = match cur.as_object_mut() {
Ok(inner) => match inner.get_mut(&Value::from(segment.as_str())) {
Some(v) => v,
None => return,
},
Err(_) => return,
};
}
cur
};
if let Value::Array(elements) = arr_val {
let inner = crate::Rc::make_mut(elements);
for elem in inner.iter_mut() {
if let Value::Object(obj_rc) = elem {
let inner_btree = crate::Rc::make_mut(obj_rc);
remap_at_depth_in_btree(
inner_btree,
array_chain,
depth.saturating_add(1),
source_field,
target_field,
lowercase,
);
}
}
}
}
/// BTreeMap-native recursion for element-level remap, avoiding ObjMap
/// round-trips on each array element.
fn remap_at_depth_in_btree(
btree: &mut alloc::collections::BTreeMap<Value, Value>,
array_chain: &[Vec<String>],
depth: usize,
source_field: &str,
target_field: &str,
lowercase: bool,
) {
let Some(nav) = array_chain.get(depth) else {
remap_deep_field_in_btree(btree, source_field, target_field, lowercase);
return;
};
let first = match nav.first() {
Some(f) => f.as_str(),
None => return,
};
let key_val = Value::from(first);
let arr_val = if nav.len() == 1 {
match btree.get_mut(&key_val) {
Some(v) => v,
None => return,
}
} else {
let mut cur: &mut Value = match btree.get_mut(&key_val) {
Some(v) => v,
None => return,
};
for segment in nav.iter().skip(1) {
cur = match cur.as_object_mut() {
Ok(inner) => match inner.get_mut(&Value::from(segment.as_str())) {
Some(v) => v,
None => return,
},
Err(_) => return,
};
}
cur
};
if let Value::Array(elements) = arr_val {
let inner = crate::Rc::make_mut(elements);
for elem in inner.iter_mut() {
if let Value::Object(obj_rc) = elem {
let inner_btree = crate::Rc::make_mut(obj_rc);
remap_at_depth_in_btree(
inner_btree,
array_chain,
depth.saturating_add(1),
source_field,
target_field,
lowercase,
);
}
}
}
}
/// Remap a value between dotted paths directly in a BTreeMap.
fn remap_deep_field_in_btree(
btree: &mut alloc::collections::BTreeMap<Value, Value>,
source: &str,
target: &str,
lowercase: bool,
) {
let val = match read_dotted_path_btree(btree, source) {
Some(v) => v,
None => return,
};
let segments: Vec<&str> = target.split('.').collect();
if segments.is_empty() {
return;
}
if segments.len() == 1 {
if let Some(seg) = segments.first() {
btree.insert(Value::String(crate::Rc::from(*seg)), val);
}
return;
}
set_nested_in_btree(btree, &segments, val, lowercase);
}
/// Read a value at a dotted path from a BTreeMap.
fn read_dotted_path_btree(
btree: &alloc::collections::BTreeMap<Value, Value>,
path: &str,
) -> Option<Value> {
let segments: Vec<&str> = path.split('.').collect();
let first = segments.first()?;
let mut cur: &Value = btree.get(&Value::from(*first))?;
for &seg in segments.iter().skip(1) {
cur = cur.as_object().ok()?.get(&Value::from(seg))?;
}
Some(cur.clone())
}
/// Remap a value from one (possibly nested) dot-separated path to another
/// in an ObjMap. Used only at the top level when `depth >= array_chain.len()`.
fn remap_deep_field(obj: &mut ObjMap, source: &str, target: &str, lowercase: bool) {
let val = match read_dotted_path(obj, source) {
Some(v) => v,
None => return,
};
let segments: Vec<&str> = target.split('.').collect();
if segments.is_empty() {
return;
}
if segments.len() == 1 {
if let Some(seg) = segments.first() {
obj_insert(obj, seg, val);
}
return;
}
if lowercase {
set_nested_lowercased(obj, target, val);
} else {
set_nested_verbatim(obj, target, val);
}
}
/// Read a value at a dot-separated path from an ObjMap.
fn read_dotted_path(obj: &ObjMap, path: &str) -> Option<Value> {
let segments: Vec<&str> = path.split('.').collect();
let first = segments.first()?;
let mut cur: &Value = obj_get(obj, first)?;
for &seg in segments.iter().skip(1) {
cur = cur.as_object().ok()?.get(&Value::from(seg))?;
}
Some(cur.clone())
}

View File

@@ -0,0 +1,132 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
//! Value normalization helpers: recursive key lowercasing, sub-resource array
//! flattening, and element merging.
use alloc::collections::BTreeSet;
use alloc::string::String;
use alloc::vec::Vec;
use crate::Value;
use super::super::obj_map::{make_array, make_value, new_map, obj_contains, obj_insert, val_str};
/// Lowercase all keys of a JSON object (shallow — values are untouched).
/// Non-object values are returned as-is.
pub fn lowercase_object_keys(value: &Value) -> Value {
match value {
Value::Object(obj) => {
let mut result = new_map();
for (k, v) in obj.iter() {
if let Some(s) = val_str(k) {
obj_insert(&mut result, &s.to_ascii_lowercase(), v.clone());
}
}
make_value(result)
}
_ => value.clone(),
}
}
/// Recursively normalize a value, flattening sub-resource array elements.
pub fn normalize_value(
value: &Value,
field_path: &str,
sub_arrays: Option<&BTreeSet<String>>,
) -> Value {
match value {
Value::Array(arr) => {
let items: Vec<Value> = if is_sub_resource_array(field_path, sub_arrays) {
arr.iter()
.map(|elem| flatten_element(elem, field_path, sub_arrays))
.collect()
} else {
arr.iter()
.map(|elem| normalize_value(elem, field_path, sub_arrays))
.collect()
};
make_array(items)
}
Value::Object(obj) => {
let mut result = new_map();
for (k, v) in obj.iter() {
let key_s = match val_str(k) {
Some(s) => s,
None => continue,
};
let child_path = alloc::format!("{}.{}", field_path, key_s);
obj_insert(
&mut result,
&key_s.to_ascii_lowercase(),
normalize_value(v, &child_path, sub_arrays),
);
}
make_value(result)
}
_ => value.clone(),
}
}
/// Flatten a sub-resource array element by merging its `properties` into
/// the element root.
pub fn flatten_element(
element: &Value,
array_path: &str,
sub_arrays: Option<&BTreeSet<String>>,
) -> Value {
let obj = match element.as_object() {
Ok(o) => o,
Err(_) => return element.clone(),
};
let mut result = new_map();
// Copy non-`properties` fields from the element envelope (keys lowercased).
for (key, val) in obj.iter() {
let key_s = match val_str(key) {
Some(s) => s,
None => continue,
};
if key_s.eq_ignore_ascii_case("properties") {
continue;
}
let child_path = alloc::format!("{}.{}", array_path, key_s);
obj_insert(
&mut result,
&key_s.to_ascii_lowercase(),
normalize_value(val, &child_path, sub_arrays),
);
}
// Merge `properties` into the element (keys lowercased).
let props_val = obj
.iter()
.find(|(k, _)| val_str(k).is_some_and(|s| s.eq_ignore_ascii_case("properties")))
.map(|(_, v)| v);
if let Some(Value::Object(props)) = props_val {
for (key, val) in props.iter() {
let key_s = match val_str(key) {
Some(s) => s,
None => continue,
};
let lc_key = key_s.to_ascii_lowercase();
if obj_contains(&result, &lc_key) {
continue;
}
let child_path = alloc::format!("{}.{}", array_path, key_s);
obj_insert(
&mut result,
&lc_key,
normalize_value(val, &child_path, sub_arrays),
);
}
}
make_value(result)
}
/// Check if a field path corresponds to a sub-resource array.
fn is_sub_resource_array(field_path: &str, sub_arrays: Option<&BTreeSet<String>>) -> bool {
sub_arrays.is_some_and(|set| set.contains(&field_path.to_ascii_lowercase()))
}

View File

@@ -0,0 +1,157 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
//! ARM JSON → normalized `input.resource` transformation.
//!
//! The normalizer flattens `properties` wrappers from raw ARM resource JSON so
//! that alias short names become direct paths into the normalized structure.
mod alias_resolution;
mod element_remap;
mod flatten;
// Re-export items used by the denormalizer.
pub(crate) use element_remap::{apply_element_remap, ElementRemap};
use crate::Value;
use super::obj_map::{
extract_type_field, make_value, new_map, obj_contains, obj_insert, val_str, ObjMap, ROOT_FIELDS,
};
use super::types::ResolvedAliases;
use super::AliasRegistry;
use flatten::{lowercase_object_keys, normalize_value};
/// Normalize a raw ARM resource JSON value into the `input.resource` structure.
///
/// The resource type is extracted from the `type` field of `arm_resource` and
/// used to look up alias entries in the registry.
pub fn normalize(
arm_resource: &Value,
registry: Option<&AliasRegistry>,
api_version: Option<&str>,
) -> Value {
let aliases = registry.and_then(|r| extract_type_field(arm_resource).and_then(|rt| r.get(rt)));
normalize_with_aliases(arm_resource, aliases, api_version)
}
/// Internal normalization with pre-resolved alias data.
///
/// Core implementation used by [`normalize`] after looking up the alias
/// entries from the registry. Also used directly in unit tests.
pub fn normalize_with_aliases(
arm_resource: &Value,
aliases: Option<&ResolvedAliases>,
api_version: Option<&str>,
) -> Value {
let obj = match arm_resource.as_object() {
Ok(o) => o,
Err(_) => return arm_resource.clone(),
};
let sub_arrays_ref = aliases.map(|a| &a.sub_resource_arrays);
let mut result = new_map();
let is_data_plane =
extract_type_field(arm_resource).is_some_and(|t| t.to_ascii_lowercase().contains(".data/"));
if is_data_plane {
for (key, val) in obj {
let key_s = match val_str(key) {
Some(s) => s,
None => continue,
};
if key_s.eq_ignore_ascii_case("properties") {
continue;
}
let lc_key = key_s.to_ascii_lowercase();
let val =
if key_s.eq_ignore_ascii_case("tags") || key_s.eq_ignore_ascii_case("identity") {
lowercase_object_keys(val)
} else {
normalize_value(val, key_s, sub_arrays_ref)
};
obj_insert(&mut result, &lc_key, val);
}
merge_properties(obj, &mut result, sub_arrays_ref);
} else {
// Copy root-level fields (keys lowercased).
for &field in ROOT_FIELDS {
let found = obj
.iter()
.find(|(k, _)| val_str(k).is_some_and(|s| s.eq_ignore_ascii_case(field)))
.map(|(_, v)| v);
if let Some(val) = found {
let val = if field.eq_ignore_ascii_case("tags")
|| field.eq_ignore_ascii_case("identity")
{
// Keep these shallow to avoid lowercasing dynamic nested-map
// keys such as userAssignedIdentities member names.
lowercase_object_keys(val)
} else {
normalize_value(val, field, sub_arrays_ref)
};
obj_insert(&mut result, &field.to_ascii_lowercase(), val);
}
}
merge_properties(obj, &mut result, sub_arrays_ref);
}
// Per-alias path resolution.
if let Some(aliases) = aliases {
alias_resolution::apply_alias_entries(&mut result, arm_resource, aliases, api_version);
}
make_value(result)
}
/// Merge `properties` fields into the result map, skipping keys that already
/// exist.
fn merge_properties(
obj: &alloc::collections::BTreeMap<Value, Value>,
result: &mut ObjMap,
sub_arrays: Option<&alloc::collections::BTreeSet<alloc::string::String>>,
) {
let props_val = obj
.iter()
.find(|(k, _)| val_str(k).is_some_and(|s| s.eq_ignore_ascii_case("properties")))
.map(|(_, v)| v);
if let Some(Value::Object(props)) = props_val {
for (key, val) in props.iter() {
let key_s = match val_str(key) {
Some(s) => s,
None => continue,
};
let lc_key = key_s.to_ascii_lowercase();
if obj_contains(result, &lc_key) {
continue;
}
let normalized = normalize_value(val, key_s, sub_arrays);
obj_insert(result, &lc_key, normalized);
}
}
}
/// Wrap a normalized resource into the full `input` envelope.
///
/// Produces: `{ "resource": <normalized>, "context": <context>, "parameters": <params> }`
pub fn build_input_envelope(
normalized_resource: Value,
context: Option<Value>,
parameters: Option<Value>,
) -> Value {
let mut envelope = new_map();
obj_insert(&mut envelope, "resource", normalized_resource);
obj_insert(
&mut envelope,
"context",
context.unwrap_or_else(|| make_value(new_map())),
);
obj_insert(
&mut envelope,
"parameters",
parameters.unwrap_or_else(|| make_value(new_map())),
);
make_value(envelope)
}

View File

@@ -0,0 +1,475 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
//! Lightweight string-keyed map used during normalization/denormalization.
//!
//! Internally uses `hashbrown::HashMap<Rc<str>, Value>` for O(1) lookups,
//! then converts to `Value::Object` (a `BTreeMap<Value, Value>`) only at
//! the output boundary via [`make_value`].
use alloc::string::{String, ToString as _};
use alloc::vec::Vec;
use hashbrown::HashMap;
use crate::Rc;
use crate::Value;
/// A string-keyed map of JSON values.
///
/// All normalizer / denormalizer code works with this type internally.
/// Convert to [`Value::Object`] via [`make_value`] when producing output.
pub type ObjMap = HashMap<Rc<str>, Value>;
/// Create an empty [`ObjMap`].
pub fn new_map() -> ObjMap {
ObjMap::new()
}
/// Look up a value by string key.
pub fn obj_get<'a>(map: &'a ObjMap, key: &str) -> Option<&'a Value> {
map.get(key)
}
/// Look up a mutable value reference by string key.
pub fn obj_get_mut<'a>(map: &'a mut ObjMap, key: &str) -> Option<&'a mut Value> {
map.get_mut(key)
}
/// Insert a key-value pair.
pub fn obj_insert(map: &mut ObjMap, key: &str, val: Value) {
map.insert(Rc::from(key), val);
}
/// Check whether a key exists.
pub fn obj_contains(map: &ObjMap, key: &str) -> bool {
map.contains_key(key)
}
/// Remove a key, returning its value if present.
pub fn obj_remove(map: &mut ObjMap, key: &str) -> Option<Value> {
map.remove(key)
}
/// Convert an [`ObjMap`] into a [`Value::Object`].
///
/// Keys are converted from `Rc<str>` to `Value::String` and inserted into
/// a `BTreeMap` to match the `Value::Object` representation.
pub fn make_value(map: ObjMap) -> Value {
use alloc::collections::BTreeMap;
let mut btree = BTreeMap::new();
for (k, v) in map {
btree.insert(Value::String(k), v);
}
Value::Object(Rc::new(btree))
}
/// Convert a `Vec<Value>` into a `Value::Array`.
pub fn make_array(items: Vec<Value>) -> Value {
Value::Array(Rc::new(items))
}
/// Extract a `&str` from a `Value::String`.
pub fn val_str(v: &Value) -> Option<&str> {
match v {
Value::String(s) => Some(s.as_ref()),
_ => None,
}
}
/// Extract the `type` field value from a resource JSON object.
///
/// Performs a case-insensitive key lookup so both `"type"` and `"Type"` work.
pub fn extract_type_field(resource: &Value) -> Option<&str> {
resource.as_object().ok().and_then(|obj| {
obj.iter()
.find(|(k, _)| val_str(k).is_some_and(|s| s.eq_ignore_ascii_case("type")))
.and_then(|(_, v)| val_str(v))
})
}
/// Convert a `Value::Object` (BTreeMap<Value, Value>) into an [`ObjMap`].
///
/// Non-string keys are silently skipped.
#[allow(dead_code)]
pub fn value_to_obj_map(value: &Value) -> Option<ObjMap> {
let btree = value.as_object().ok()?;
let mut map = ObjMap::with_capacity(btree.len());
for (k, v) in btree.iter() {
if let Value::String(s) = k {
map.insert(Rc::clone(s), v.clone());
}
}
Some(map)
}
/// Set a value at a dot-separated path in an [`ObjMap`], creating
/// intermediate `Value::Object` nodes as needed. All keys are lowercased.
pub fn set_nested_lowercased(result: &mut ObjMap, path: &str, value: Value) {
let segments: Vec<&str> = path.split('.').collect();
if segments.is_empty() {
return;
}
if segments.len() == 1 {
if let Some(&seg) = segments.first() {
obj_insert(result, &seg.to_ascii_lowercase(), value);
}
return;
}
// Build the nested structure from inside-out.
set_nested_inner(result, &segments, value, true);
}
/// Set a value at a dot-separated path in an [`ObjMap`], creating
/// intermediate `Value::Object` nodes as needed. Keys preserve their casing.
pub fn set_nested_verbatim(result: &mut ObjMap, path: &str, value: Value) {
let segments: Vec<&str> = path.split('.').collect();
if segments.is_empty() {
return;
}
if segments.len() == 1 {
if let Some(&seg) = segments.first() {
obj_insert(result, seg, value);
}
return;
}
set_nested_inner(result, &segments, value, false);
}
/// Core implementation of nested-set. Navigates the first N-1 segments,
/// creating intermediate objects, then inserts the value at the last segment.
fn set_nested_inner(obj: &mut ObjMap, segments: &[&str], value: Value, lowercase: bool) {
let Some(&first) = segments.first() else {
return;
};
if segments.len() == 1 {
let key = if lowercase {
first.to_ascii_lowercase()
} else {
first.to_string()
};
obj_insert(obj, &key, value);
return;
}
let seg = if lowercase {
first.to_ascii_lowercase()
} else {
first.to_string()
};
// Ensure an intermediate object exists at `seg`.
if !obj_contains(obj, &seg) {
obj_insert(obj, &seg, make_value(new_map()));
}
// Descend directly into the BTreeMap, avoiding ObjMap round-trip.
if let Some(Value::Object(inner_rc)) = obj_get_mut(obj, &seg) {
let inner_btree = Rc::make_mut(inner_rc);
set_nested_in_btree(
inner_btree,
segments.get(1..).unwrap_or_default(),
value,
lowercase,
);
}
}
/// Set a value at a path directly in a `BTreeMap<Value, Value>`, creating
/// intermediate `Value::Object` nodes as needed.
///
/// This avoids the `btree_to_obj_map` / `obj_map_to_btree` round-trip that
/// would clone every sibling entry at each nesting level.
pub fn set_nested_in_btree(
btree: &mut alloc::collections::BTreeMap<Value, Value>,
segments: &[&str],
value: Value,
lowercase: bool,
) {
let Some(&first) = segments.first() else {
return;
};
let key_str: String = if lowercase {
first.to_ascii_lowercase()
} else {
first.to_string()
};
let key_val = Value::String(Rc::from(key_str.as_str()));
if segments.len() == 1 {
btree.insert(key_val, value);
return;
}
// Ensure an intermediate object exists.
if !btree.contains_key(&key_val) {
btree.insert(key_val.clone(), make_value(new_map()));
}
if let Some(Value::Object(inner_rc)) = btree.get_mut(&key_val) {
let inner = Rc::make_mut(inner_rc);
set_nested_in_btree(
inner,
segments.get(1..).unwrap_or_default(),
value,
lowercase,
);
}
}
/// Fields that exist at the ARM resource root (not under `properties`).
///
/// These are the standard ARM resource envelope fields as defined by the
/// Azure Resource Manager resource model. They are preserved at the
/// resource root during normalization and denormalization.
pub const ROOT_FIELDS: &[&str] = &[
"name",
"type",
"location",
"kind",
"id",
"tags",
"identity",
"sku",
"plan",
"zones",
"managedBy",
"etag",
"apiVersion",
"fullName",
"systemData",
"extendedLocation",
];
/// Check whether an alias short name collides with a reserved ARM root field
/// and needs a collision-safe key.
pub fn is_root_field_collision(short_name: &str, default_path: &str) -> bool {
ROOT_FIELDS
.iter()
.any(|f| f.eq_ignore_ascii_case(short_name))
&& default_path.to_ascii_lowercase().starts_with("properties.")
}
/// Return a collision-safe key for an alias whose short name collides with a
/// root ARM field. The key is `_p_` + the lowercased short name.
pub fn collision_safe_key(short_name: &str) -> String {
alloc::format!("_p_{}", short_name.to_ascii_lowercase())
}
// ─── Element-level field removal ────────────────────────────────────────────
//
// Shared by both normalizer (stale source cleanup after remap) and
// denormalizer (cleanup after reverse remap).
/// Remove a (possibly dot-separated) field from each element of a (possibly
/// nested) array, navigating via the given `array_chain`.
pub fn remove_element_field(obj: &mut ObjMap, array_chain: &[Vec<String>], field: &str) {
remove_field_at_depth(obj, array_chain, 0, field);
}
fn remove_field_at_depth(obj: &mut ObjMap, array_chain: &[Vec<String>], depth: usize, field: &str) {
let Some(nav) = array_chain.get(depth) else {
let segments: Vec<&str> = field.split('.').collect();
if segments.len() == 1 {
if let Some(&seg) = segments.first() {
obj_remove(obj, seg);
}
} else if segments.len() > 1 {
remove_at_dotted_path(obj, &segments);
}
return;
};
let first = match nav.first() {
Some(f) => f.as_str(),
None => return,
};
let arr_val = if nav.len() == 1 {
match obj_get_mut(obj, first) {
Some(v) => v,
None => return,
}
} else {
let mut cur: &mut Value = match obj_get_mut(obj, first) {
Some(v) => v,
None => return,
};
for segment in nav.iter().skip(1) {
cur = match cur.as_object_mut() {
Ok(inner) => match inner.get_mut(&Value::from(segment.as_str())) {
Some(v) => v,
None => return,
},
Err(_) => return,
};
}
cur
};
if let Value::Array(elements) = arr_val {
let inner = Rc::make_mut(elements);
for elem in inner.iter_mut() {
if let Value::Object(obj_rc) = elem {
let inner_btree = Rc::make_mut(obj_rc);
remove_field_at_depth_in_btree(
inner_btree,
array_chain,
depth.saturating_add(1),
field,
);
}
}
}
}
/// BTreeMap-native recursion for element-level field removal.
fn remove_field_at_depth_in_btree(
btree: &mut alloc::collections::BTreeMap<Value, Value>,
array_chain: &[Vec<String>],
depth: usize,
field: &str,
) {
let Some(nav) = array_chain.get(depth) else {
let segments: Vec<&str> = field.split('.').collect();
if segments.len() == 1 {
if let Some(&seg) = segments.first() {
btree.remove(&Value::from(seg));
}
} else if segments.len() > 1 {
remove_at_dotted_path_in_btree(btree, &segments);
}
return;
};
let first = match nav.first() {
Some(f) => f.as_str(),
None => return,
};
let key_val = Value::from(first);
let arr_val = if nav.len() == 1 {
match btree.get_mut(&key_val) {
Some(v) => v,
None => return,
}
} else {
let mut cur: &mut Value = match btree.get_mut(&key_val) {
Some(v) => v,
None => return,
};
for segment in nav.iter().skip(1) {
cur = match cur.as_object_mut() {
Ok(inner) => match inner.get_mut(&Value::from(segment.as_str())) {
Some(v) => v,
None => return,
},
Err(_) => return,
};
}
cur
};
if let Value::Array(elements) = arr_val {
let inner = Rc::make_mut(elements);
for elem in inner.iter_mut() {
if let Value::Object(obj_rc) = elem {
let inner_btree = Rc::make_mut(obj_rc);
remove_field_at_depth_in_btree(
inner_btree,
array_chain,
depth.saturating_add(1),
field,
);
}
}
}
}
/// Remove the leaf segment at a dotted path directly in a BTreeMap.
fn remove_at_dotted_path_in_btree(
btree: &mut alloc::collections::BTreeMap<Value, Value>,
segments: &[&str],
) {
let Some((&leaf, parent_segs)) = segments.split_last() else {
return;
};
if parent_segs.is_empty() {
btree.remove(&Value::from(leaf));
return;
}
let Some(&first) = parent_segs.first() else {
return;
};
let first_key = Value::from(first);
let parent_val = match btree.get_mut(&first_key) {
Some(v) => v,
None => return,
};
if parent_segs.len() == 1 {
if let Value::Object(inner_rc) = parent_val {
let inner_btree = Rc::make_mut(inner_rc);
inner_btree.remove(&Value::from(leaf));
}
} else {
let mut cur = parent_val;
for &seg in parent_segs.iter().skip(1) {
cur = match cur.as_object_mut() {
Ok(inner) => match inner.get_mut(&Value::from(seg)) {
Some(v) => v,
None => return,
},
Err(_) => return,
};
}
if let Value::Object(inner_rc) = cur {
let inner_btree = Rc::make_mut(inner_rc);
inner_btree.remove(&Value::from(leaf));
}
}
}
/// Remove the leaf segment at a dot-separated path from an ObjMap.
fn remove_at_dotted_path(obj: &mut ObjMap, segments: &[&str]) {
let Some((&leaf, parent_segs)) = segments.split_last() else {
return;
};
if parent_segs.is_empty() {
obj_remove(obj, leaf);
return;
}
let Some(&first) = parent_segs.first() else {
return;
};
let parent_val = match obj_get_mut(obj, first) {
Some(v) => v,
None => return,
};
if parent_segs.len() == 1 {
if let Value::Object(inner_rc) = parent_val {
let inner_btree = Rc::make_mut(inner_rc);
inner_btree.remove(&Value::from(leaf));
}
} else {
let mut cur = parent_val;
for &seg in parent_segs.iter().skip(1) {
cur = match cur.as_object_mut() {
Ok(inner) => match inner.get_mut(&Value::from(seg)) {
Some(v) => v,
None => return,
},
Err(_) => return,
};
}
if let Value::Object(inner_rc) = cur {
let inner_btree = Rc::make_mut(inner_rc);
inner_btree.remove(&Value::from(leaf));
}
}
}

View File

@@ -0,0 +1,686 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
//! Data types for Azure Policy alias definitions.
//!
//! These types deserialize production alias catalog data from multiple sources:
//! 1. ARM API response: `GET /providers?$expand=resourceTypes/aliases`
//! 2. Static `ResourceTypesAndAliases.json` (used by PolicyTester)
//! 3. `az provider list --expand resourceTypes/aliases` CLI output
//! (where `defaultPath` may be serialized as `{ path, apiVersions }`)
//!
//! All data is captured for completeness; fields not yet used by the compiler
//! are retained so the types stay in sync with the production schema.
use alloc::collections::{BTreeMap, BTreeSet};
use alloc::string::String;
use alloc::vec::Vec;
use serde::{Deserialize, Deserializer};
// ─── Top-level response wrappers ────────────────────────────────────────────
/// ARM API response envelope: `{ "value": [...] }`
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct ArmProvidersResponse {
pub value: Vec<ProviderAliases>,
/// Pagination link (ARM may paginate large responses).
#[serde(rename = "nextLink", default)]
pub next_link: Option<String>,
}
// ─── Provider / resource type ───────────────────────────────────────────────
/// A resource provider's alias definitions.
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct ProviderAliases {
/// The provider namespace (e.g., `"Microsoft.Storage"`).
pub namespace: String,
/// Resource types with their alias entries.
#[serde(default, rename = "resourceTypes")]
pub resource_types: Vec<ResourceTypeAliases>,
}
/// Aliases for a single resource type within a provider.
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct ResourceTypeAliases {
/// The resource type name (e.g., `"storageAccounts"`).
pub resource_type: String,
/// All alias entries for this resource type.
#[serde(default)]
pub aliases: Vec<AliasEntry>,
/// Resource capabilities as a comma-separated string
/// (e.g., `"SupportsTags, SupportsLocation"`).
#[serde(default)]
pub capabilities: Option<String>,
/// Default API version for the resource type.
#[serde(default)]
pub default_api_version: Option<String>,
}
// ─── Alias ──────────────────────────────────────────────────────────────────
/// A single alias entry within a resource type.
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AliasEntry {
/// Fully qualified alias name
/// (e.g., `"Microsoft.Storage/storageAccounts/supportsHttpsTrafficOnly"`).
pub name: String,
/// The default ARM JSON path used when no versioned path matches.
///
/// In most formats this is a plain string. The `az CLI` format serializes
/// it as `{ "path": "...", "apiVersions": [...] }`. The custom
/// deserializer accepts both, extracting just the path string.
#[serde(default, deserialize_with = "deserialize_default_path")]
pub default_path: Option<String>,
/// Optional metadata for the default path (type, modifiability).
#[serde(default)]
pub default_metadata: Option<AliasPathMetadata>,
/// Extraction pattern for the default path (present in ARM responses for
/// some aliases; absent from the static file).
#[serde(default)]
pub default_pattern: Option<AliasPattern>,
/// Alias-level type as a comma-separated string of flags:
/// `"PlainText"`, `"Mask"`, `"Deprecated"`, `"Preview"`, or combinations
/// like `"Mask, Deprecated"`. `None` when absent.
#[serde(default, rename = "type")]
pub alias_type: Option<String>,
/// Versioned path entries. Empty for the vast majority of aliases that
/// have only a `defaultPath`.
#[serde(default)]
pub paths: Vec<AliasPath>,
}
// ─── Alias path ─────────────────────────────────────────────────────────────
/// A versioned path mapping for an alias.
///
/// When an alias maps to different ARM JSON paths across API versions, each
/// distinct path is recorded as an `AliasPath` entry.
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AliasPath {
/// The ARM JSON path (e.g., `"properties.encryption.services.blob.enabled"`).
pub path: String,
/// API versions for which this path is valid. Empty means all versions.
#[serde(default)]
pub api_versions: Vec<String>,
/// Optional per-version metadata.
#[serde(default)]
pub metadata: Option<AliasPathMetadata>,
/// Extraction pattern for this specific path.
#[serde(default)]
pub pattern: Option<AliasPattern>,
}
// ─── Alias path metadata ───────────────────────────────────────────────────
/// Metadata associated with an alias path (default or versioned).
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct AliasPathMetadata {
/// The data type of the alias value as a string token
/// (e.g., `"String"`, `"Integer"`, `"Boolean"`, `"Array"`, `"Object"`).
#[serde(rename = "type")]
pub kind: Option<String>,
/// Attribute flags as a comma-separated string
/// (e.g., `"Modifiable"`, `"Modifiable, SupportsCreate, SupportsRead"`).
pub attributes: Option<String>,
}
// ─── Alias pattern ──────────────────────────────────────────────────────────
/// Extraction pattern for an alias path (URI template or regex).
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AliasPattern {
/// The pattern phrase (URI template or regex string).
pub phrase: String,
/// The variable to extract from the pattern.
#[serde(default)]
pub variable: Option<String>,
/// Pattern type (e.g., `"Extract"`).
#[serde(default, rename = "type")]
pub pattern_type: Option<String>,
}
// ─── Custom deserializer: defaultPath (string or object) ────────────────────
/// Accepts either a plain string `"properties.foo"` or an az CLI object
/// `{ "path": "properties.foo", "apiVersions": [...] }`, extracting just the
/// path string. Handles `null` gracefully.
fn deserialize_default_path<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum RawDefaultPath {
Str(String),
Obj {
path: String,
#[serde(default, rename = "apiVersions")]
_api_versions: Vec<String>,
},
Null,
}
match Option::<RawDefaultPath>::deserialize(deserializer)? {
None | Some(RawDefaultPath::Null) => Ok(None),
Some(RawDefaultPath::Str(s)) => Ok(Some(s)),
Some(RawDefaultPath::Obj { path, .. }) => Ok(Some(path)),
}
}
// ─── Data Policy Manifest types (data-plane aliases) ────────────────────────
/// A single alias entry in a data policy manifest.
///
/// Unlike control-plane [`AliasEntry`], data-plane aliases have no
/// `defaultPath` — the path is always taken from `paths[0].path`.
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct DataManifestAlias {
/// Fully qualified alias name
/// (e.g., `"Microsoft.KeyVault.Data/vaults/certificates/attributes.expiresOn"`).
pub name: String,
/// Versioned path entries. `paths[0].path` serves as the default path.
#[serde(default)]
pub paths: Vec<DataManifestAliasPath>,
}
/// A path entry in a data policy manifest alias.
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct DataManifestAliasPath {
/// The ARM JSON path (e.g., `"attributes.expiresOn"`).
pub path: String,
/// API versions for which this path is valid.
#[serde(default)]
pub api_versions: Vec<String>,
/// Schema versions for which this path is valid (used by some data-plane
/// providers instead of `apiVersions`).
#[serde(default)]
pub schema_versions: Vec<String>,
}
/// Per-resource-type alias group in a data policy manifest.
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct DataManifestResourceTypeAliases {
/// Resource type suffix (e.g., `"vaults/certificates"`).
pub resource_type: String,
/// Aliases for this resource type.
#[serde(default)]
pub aliases: Vec<DataManifestAlias>,
}
/// A data policy manifest describing data-plane aliases for a namespace.
///
/// This format is used by `dataPolicyManifests/` files, as opposed to the
/// control-plane `ProviderAliases` format used by `Get-AzPolicyAlias`.
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct DataPolicyManifest {
/// The data namespace (e.g., `"Microsoft.KeyVault.Data"`).
pub data_namespace: String,
/// Top-level aliases not scoped to a specific resource type.
#[serde(default)]
pub aliases: Vec<DataManifestAlias>,
/// Per-resource-type alias groups.
#[serde(default)]
pub resource_type_aliases: Vec<DataManifestResourceTypeAliases>,
}
// ─── Convenience loading functions ──────────────────────────────────────────
/// Load from the static `ResourceTypesAndAliases.json` file (bare array).
pub fn load_from_static_file(json: &str) -> Result<Vec<ProviderAliases>, serde_json::Error> {
serde_json::from_str(json)
}
/// Load from an ARM API `GET /providers` response (`{ "value": [...] }`).
pub fn load_from_arm_response(json: &str) -> Result<Vec<ProviderAliases>, serde_json::Error> {
let resp: ArmProvidersResponse = serde_json::from_str(json)?;
Ok(resp.value)
}
/// Load from either format: tries ARM envelope first, then bare array.
pub fn load_auto(json: &str) -> Result<Vec<ProviderAliases>, serde_json::Error> {
let trimmed = json.trim_start();
if trimmed.starts_with('[') {
load_from_static_file(json)
} else {
load_from_arm_response(json)
}
}
// ─── Utility methods ────────────────────────────────────────────────────────
impl AliasEntry {
/// Returns `true` if this alias is marked as deprecated (case-insensitive).
pub fn is_deprecated(&self) -> bool {
has_flag(self.alias_type.as_deref(), "Deprecated")
}
/// Returns `true` if this alias is marked as preview.
pub fn is_preview(&self) -> bool {
has_flag(self.alias_type.as_deref(), "Preview")
}
/// Returns `true` if this alias's value should be masked (secret).
pub fn is_secret(&self) -> bool {
has_flag(self.alias_type.as_deref(), "Mask")
}
/// Returns the effective path string (defaultPath or first versioned path).
pub fn effective_path(&self) -> Option<&str> {
self.default_path
.as_deref()
.or_else(|| self.paths.first().map(|p| p.path.as_str()))
}
}
impl AliasPathMetadata {
/// Returns `true` if this path supports modification (Modifiable flag).
pub fn is_modifiable(&self) -> bool {
has_flag(self.attributes.as_deref(), "Modifiable")
}
}
/// Check whether a comma-separated flags string contains a specific flag
/// (case-insensitive).
pub(crate) fn has_flag(flags: Option<&str>, flag: &str) -> bool {
flags.is_some_and(|s| {
s.split(',')
.any(|part| part.trim().eq_ignore_ascii_case(flag))
})
}
/// Parsed alias data for a single resource type, keyed by short name.
///
/// The short name is derived by stripping the resource type prefix from the
/// fully qualified alias name:
/// `Microsoft.Storage/storageAccounts/sku.name` → `sku.name`
#[derive(Debug, Clone)]
pub struct ResolvedAliases {
/// Fully qualified resource type (e.g., `"Microsoft.Storage/storageAccounts"`).
pub resource_type: String,
/// Map from alias short name (case-insensitive key, stored lowercase) to
/// the resolved ARM path.
pub entries: BTreeMap<String, ResolvedEntry>,
/// Array field names whose elements are sub-resources (have their own
/// `properties` wrapper to flatten). Stored pre-lowercased so consumers
/// can look up directly without per-call allocation.
pub sub_resource_arrays: BTreeSet<String>,
// ── Precomputed aggregate fields ────────────────────────────────────
/// Precomputed aggregates for the default path (api_version = None).
pub default_aggregates: VersionedAggregates,
/// Precomputed aggregates keyed by lowercase api_version string.
/// Computed at registry-load time for every distinct version found in
/// any entry's `versioned_paths`.
pub versioned_aggregates: BTreeMap<String, VersionedAggregates>,
}
/// Precomputed aggregate data for a specific API version, or for the default path.
///
/// Stored at [`ResolvedAliases`] level so it is computed once at registry-load
/// time rather than reconstructed on every normalize/denormalize call.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct VersionedAggregates {
/// Precomputed element-level field remaps for wildcard aliases.
pub element_remaps: Vec<PrecomputedRemap>,
/// Precomputed reverse element remaps for denormalization.
pub reverse_element_remaps: Vec<PrecomputedReverseRemap>,
/// Deduplicated array base renames for normalization: `(arm_base_lc, short_base_lc)`.
pub array_renames_normalize: Vec<(String, String)>,
/// Deduplicated array base renames for denormalization: `(short_base_lc, arm_base)`.
pub array_renames_denormalize: Vec<(String, String)>,
}
/// A precomputed element-level field remap, stored at `ResolvedAliases` level
/// so it's computed once at registry-load time rather than per-call.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PrecomputedRemap {
/// Chain of array navigations for nested `[*]` levels.
pub array_chain: Vec<Vec<String>>,
/// Field to read within each element.
pub source_field: String,
/// Field to write within each element.
pub target_field: String,
}
/// A precomputed reverse remap for denormalization, including the forward
/// target field name for cleanup after remapping.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PrecomputedReverseRemap {
/// Chain of array navigations for nested `[*]` levels.
pub array_chain: Vec<Vec<String>>,
/// Field to read within each element (was the forward target).
pub source_field: String,
/// Field to write within each element (was the forward source).
pub target_field: String,
/// The forward target field to remove after remapping.
pub cleanup_field: String,
}
/// A resolved alias entry with its default path and optional versioned paths.
#[derive(Debug, Clone)]
pub struct ResolvedEntry {
/// The original-cased alias short name (e.g., `"accountType"`, not
/// `"accounttype"`). The entries map uses lowercase keys for
/// case-insensitive lookup, but the normalizer needs the original casing
/// to write values at correctly-cased paths in the output.
pub short_name: String,
/// The default ARM JSON path.
pub default_path: String,
/// Versioned path overrides: `(api_version, arm_path)` pairs.
pub versioned_paths: Vec<(String, String)>,
/// Optional metadata from the alias catalog (type, modifiability).
pub metadata: Option<AliasPathMetadata>,
// ── Precomputed fields (derived at registry-load time) ──────────────
/// Whether `short_name` contains `[*]` (i.e., this is a wildcard/array alias).
pub is_wildcard: bool,
/// Precomputed `default_path.split('.').collect()` for fast ARM path navigation.
pub default_path_segments: Vec<String>,
/// Precomputed path segments for each versioned path, in the same order
/// as `versioned_paths`.
pub versioned_path_segments: Vec<Vec<String>>,
}
impl ResolvedEntry {
/// Build a `ResolvedEntry` and precompute derived fields.
pub fn new(
short_name: String,
default_path: String,
versioned_paths: Vec<(String, String)>,
metadata: Option<AliasPathMetadata>,
) -> Self {
let is_wildcard = short_name.contains("[*]");
let default_path_segments = default_path.split('.').map(String::from).collect();
let versioned_path_segments = versioned_paths
.iter()
.map(|(_, p)| p.split('.').map(String::from).collect())
.collect();
Self {
short_name,
default_path,
versioned_paths,
metadata,
is_wildcard,
default_path_segments,
versioned_path_segments,
}
}
/// Select the ARM path for a given API version.
///
/// If `api_version` is `Some` and matches a versioned path, returns that
/// path. Otherwise returns the `default_path`.
pub fn select_path(&self, api_version: Option<&str>) -> &str {
if let Some(ver) = api_version {
for (v, path) in &self.versioned_paths {
if v.eq_ignore_ascii_case(ver) {
return path;
}
}
}
&self.default_path
}
/// Select pre-tokenized path segments for a given API version.
///
/// Returns the versioned segments if `api_version` matches, otherwise
/// the default segments. This avoids per-call `split('.')` for both
/// default and versioned scalar alias navigation.
pub fn select_path_segments(&self, api_version: Option<&str>) -> &[String] {
if let Some(ver) = api_version {
for (i, (v, _)) in self.versioned_paths.iter().enumerate() {
if v.eq_ignore_ascii_case(ver) {
if let Some(segs) = self.versioned_path_segments.get(i) {
return segs;
}
}
}
}
&self.default_path_segments
}
}
#[cfg(test)]
#[allow(clippy::indexing_slicing, clippy::unwrap_used)]
mod tests {
use alloc::string::ToString as _;
use alloc::vec;
use super::*;
fn make_entry(short: &str, default: &str, versioned: Vec<(&str, &str)>) -> ResolvedEntry {
ResolvedEntry::new(
short.to_string(),
default.to_string(),
versioned
.into_iter()
.map(|(v, p)| (v.to_string(), p.to_string()))
.collect(),
None,
)
}
#[test]
fn select_path_no_version_returns_default() {
let entry = make_entry(
"enabled",
"properties.enabled",
vec![("2020-01-01", "properties.isEnabled")],
);
assert_eq!(entry.select_path(None), "properties.enabled");
}
#[test]
fn select_path_matching_version() {
let entry = make_entry(
"enabled",
"properties.enabled",
vec![
("2020-01-01", "properties.isEnabled"),
("2021-06-01", "properties.enabled"),
],
);
assert_eq!(
entry.select_path(Some("2020-01-01")),
"properties.isEnabled"
);
}
#[test]
fn select_path_no_matching_version_returns_default() {
let entry = make_entry(
"enabled",
"properties.enabled",
vec![("2020-01-01", "properties.isEnabled")],
);
assert_eq!(entry.select_path(Some("9999-01-01")), "properties.enabled");
}
#[test]
fn select_path_case_insensitive_version() {
let entry = make_entry(
"enabled",
"properties.enabled",
vec![("2020-01-01-Preview", "properties.isEnabled")],
);
assert_eq!(
entry.select_path(Some("2020-01-01-preview")),
"properties.isEnabled"
);
}
#[test]
fn select_path_empty_versioned_paths() {
let entry = make_entry("enabled", "properties.enabled", vec![]);
assert_eq!(entry.select_path(Some("2020-01-01")), "properties.enabled");
}
#[test]
fn deserialize_provider_aliases() {
let json = r#"{
"namespace": "Microsoft.Storage",
"resourceTypes": [
{
"resourceType": "storageAccounts",
"aliases": [
{
"name": "Microsoft.Storage/storageAccounts/sku.name",
"defaultPath": "sku.name",
"defaultMetadata": { "type": "String", "attributes": "Modifiable" },
"paths": []
},
{
"name": "Microsoft.Storage/storageAccounts/accessTier",
"defaultPath": "properties.accessTier",
"paths": [
{
"path": "properties.accessTier",
"apiVersions": ["2021-01-01", "2020-08-01-preview"],
"metadata": { "type": "String" }
}
]
}
]
}
]
}"#;
let provider: ProviderAliases = serde_json::from_str(json).unwrap();
assert_eq!(provider.namespace, "Microsoft.Storage");
assert_eq!(provider.resource_types.len(), 1);
let rt = &provider.resource_types[0];
assert_eq!(rt.resource_type, "storageAccounts");
assert_eq!(rt.aliases.len(), 2);
let sku_alias = &rt.aliases[0];
assert_eq!(sku_alias.default_path.as_deref(), Some("sku.name"));
assert!(sku_alias.paths.is_empty());
let access_alias = &rt.aliases[1];
assert_eq!(access_alias.paths.len(), 1);
assert_eq!(access_alias.paths[0].api_versions.len(), 2);
}
#[test]
fn deserialize_alias_metadata() {
let json = r#"{
"name": "test/alias",
"defaultPath": "properties.value",
"defaultMetadata": { "type": "Integer", "attributes": "None" },
"paths": []
}"#;
let entry: AliasEntry = serde_json::from_str(json).unwrap();
let meta = entry.default_metadata.unwrap();
assert_eq!(meta.kind.as_deref(), Some("Integer"));
assert_eq!(meta.attributes.as_deref(), Some("None"));
}
#[test]
fn deserialize_az_cli_default_path_object() {
let json = r#"{
"name": "Microsoft.Compute/virtualMachines/sku.name",
"defaultPath": {
"path": "properties.hardwareProfile.vmSize",
"apiVersions": ["2024-07-01"]
},
"paths": []
}"#;
let entry: AliasEntry = serde_json::from_str(json).unwrap();
assert_eq!(
entry.default_path.as_deref(),
Some("properties.hardwareProfile.vmSize")
);
}
#[test]
fn alias_type_flags() {
let json = r#"{
"name": "test",
"type": "Mask, Deprecated",
"defaultMetadata": { "type": "String", "attributes": "Modifiable, SupportsCreate" },
"paths": []
}"#;
let entry: AliasEntry = serde_json::from_str(json).unwrap();
assert!(entry.is_secret());
assert!(entry.is_deprecated());
assert!(!entry.is_preview());
assert!(entry.default_metadata.as_ref().unwrap().is_modifiable());
}
#[test]
fn load_auto_detects_format() {
let array_json = r#"[{"namespace":"N","resourceTypes":[]}]"#;
let arm_json = r#"{"value":[{"namespace":"N","resourceTypes":[]}]}"#;
assert_eq!(load_auto(array_json).unwrap()[0].namespace, "N");
assert_eq!(load_auto(arm_json).unwrap()[0].namespace, "N");
}
#[test]
fn deserialize_pattern() {
let json = r#"{
"name": "test",
"defaultPath": "p",
"paths": [{
"path": "p",
"apiVersions": ["2020-01-01"],
"pattern": {
"phrase": "/Subscriptions/{sub}/Providers/{prov}",
"variable": "prov",
"type": "Extract"
}
}]
}"#;
let entry: AliasEntry = serde_json::from_str(json).unwrap();
let pattern = entry.paths[0].pattern.as_ref().unwrap();
assert_eq!(pattern.pattern_type.as_deref(), Some("Extract"));
assert_eq!(pattern.variable.as_deref(), Some("prov"));
}
#[test]
fn capabilities_preserved() {
let json = r#"{
"namespace": "NS",
"resourceTypes": [{
"resourceType": "rt",
"capabilities": "SupportsTags, SupportsLocation",
"aliases": []
}]
}"#;
let provider: ProviderAliases = serde_json::from_str(json).unwrap();
assert_eq!(
provider.resource_types[0].capabilities.as_deref(),
Some("SupportsTags, SupportsLocation")
);
}
}

View File

@@ -3,4 +3,6 @@
//! Azure Policy language support.
#[allow(clippy::pattern_type_mismatch)]
pub mod aliases;
pub mod strings;

View File

@@ -1,7 +0,0 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
//! Language-specific modules for specialized parsing and evaluation
#[cfg(feature = "azure-rbac")]
pub mod azure_rbac;