mirror of
https://github.com/microsoft/regorus.git
synced 2026-08-05 02:16:11 +00:00
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:
committed by
GitHub
parent
35fb5d5953
commit
d36f952133
114
src/languages/azure_policy/aliases/denormalizer/casing.rs
Normal file
114
src/languages/azure_policy/aliases/denormalizer/casing.rs
Normal 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(),
|
||||
}
|
||||
}
|
||||
13
src/languages/azure_policy/aliases/denormalizer/helpers.rs
Normal file
13
src/languages/azure_policy/aliases/denormalizer/helpers.rs
Normal 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()
|
||||
}
|
||||
227
src/languages/azure_policy/aliases/denormalizer/mod.rs
Normal file
227
src/languages/azure_policy/aliases/denormalizer/mod.rs
Normal 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)
|
||||
}
|
||||
228
src/languages/azure_policy/aliases/denormalizer/sub_resource.rs
Normal file
228
src/languages/azure_policy/aliases/denormalizer/sub_resource.rs
Normal 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)
|
||||
}
|
||||
55
src/languages/azure_policy/aliases/denormalizer/tests.rs
Normal file
55
src/languages/azure_policy/aliases/denormalizer/tests.rs
Normal 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()));
|
||||
}
|
||||
1310
src/languages/azure_policy/aliases/mod.rs
Normal file
1310
src/languages/azure_policy/aliases/mod.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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())
|
||||
}
|
||||
252
src/languages/azure_policy/aliases/normalizer/element_remap.rs
Normal file
252
src/languages/azure_policy/aliases/normalizer/element_remap.rs
Normal 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())
|
||||
}
|
||||
132
src/languages/azure_policy/aliases/normalizer/flatten.rs
Normal file
132
src/languages/azure_policy/aliases/normalizer/flatten.rs
Normal 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()))
|
||||
}
|
||||
157
src/languages/azure_policy/aliases/normalizer/mod.rs
Normal file
157
src/languages/azure_policy/aliases/normalizer/mod.rs
Normal 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)
|
||||
}
|
||||
475
src/languages/azure_policy/aliases/obj_map.rs
Normal file
475
src/languages/azure_policy/aliases/obj_map.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
686
src/languages/azure_policy/aliases/types.rs
Normal file
686
src/languages/azure_policy/aliases/types.rs
Normal 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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,4 +3,6 @@
|
||||
|
||||
//! Azure Policy language support.
|
||||
|
||||
#[allow(clippy::pattern_type_mismatch)]
|
||||
pub mod aliases;
|
||||
pub mod strings;
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user