mirror of
https://github.com/microsoft/regorus.git
synced 2026-08-05 02:16:11 +00:00
5467cd9e69
Replace the compiler's two cloned BTreeMap fields (alias_map and alias_modifiable) with a single Option<Rc<AliasRegistry>>. This eliminates cloning two 73K-entry maps on every compilation by sharing the registry through a reference-counted pointer. Additional improvements: - alias_map()/alias_modifiable_map() return &BTreeMap (zero-copy) - Safe .get() indexing in ingest_alias_entries and build_object_from_keys - Null-tolerant deserialization for AliasEntry.paths (~97% of real Azure catalog entries emit "paths": null) All changes are within the azure_policy feature gate. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1330 lines
50 KiB
Rust
1330 lines
50 KiB
Rust
// Copyright (c) Microsoft Corporation.
|
|
// Licensed under the MIT License.
|
|
|
|
//! Azure Policy alias resolution and ARM resource normalization.
|
|
//!
|
|
//! This module provides:
|
|
//! - [`types`]: Data types for deserializing production alias catalogs
|
|
//! - [`normalizer`]: ARM JSON → normalized `input.resource` transformation
|
|
//!
|
|
//! # Overview
|
|
//!
|
|
//! Azure Policy aliases are short names for ARM JSON paths. For example,
|
|
//! `Microsoft.Storage/storageAccounts/supportsHttpsTrafficOnly` maps to the
|
|
//! ARM path `properties.supportsHttpsTrafficOnly`.
|
|
//!
|
|
//! The normalizer transforms raw ARM resource JSON into a flat structure where
|
|
//! alias short names are direct paths. This means the compiler and VM never
|
|
//! need to know about aliases — the normalizer handles the translation once
|
|
//! before evaluation.
|
|
|
|
pub mod denormalizer;
|
|
pub mod normalizer;
|
|
pub(crate) mod obj_map;
|
|
pub mod types;
|
|
|
|
use alloc::collections::{BTreeMap, BTreeSet};
|
|
use alloc::string::{String, ToString as _};
|
|
use alloc::vec::Vec;
|
|
|
|
use anyhow::Result;
|
|
|
|
use types::{
|
|
AliasEntry, AliasPath, DataPolicyManifest, PrecomputedRemap, PrecomputedReverseRemap,
|
|
ProviderAliases, ResolvedAliases, ResolvedEntry, VersionedAggregates,
|
|
};
|
|
|
|
use obj_map::{collision_safe_key, is_root_field_collision};
|
|
|
|
/// Registry of resolved alias data, keyed by fully-qualified resource type
|
|
/// (case-insensitive, stored lowercase).
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct AliasRegistry {
|
|
/// Map from lowercase resource type → resolved alias data.
|
|
types: BTreeMap<String, ResolvedAliases>,
|
|
/// Global reverse lookup: lowercase fully-qualified alias name → short name.
|
|
///
|
|
/// Built during [`load_provider`] so the compiler can resolve any alias
|
|
/// to its short name without knowing the resource type.
|
|
alias_to_short: BTreeMap<String, String>,
|
|
/// Global lookup: lowercase fully-qualified alias name → modifiable flag.
|
|
///
|
|
/// `true` when `defaultMetadata.attributes == "Modifiable"`, `false` otherwise.
|
|
alias_modifiable: BTreeMap<String, bool>,
|
|
}
|
|
|
|
impl AliasRegistry {
|
|
/// Create a new empty registry.
|
|
pub const fn new() -> Self {
|
|
Self {
|
|
types: BTreeMap::new(),
|
|
alias_to_short: BTreeMap::new(),
|
|
alias_modifiable: BTreeMap::new(),
|
|
}
|
|
}
|
|
|
|
/// Load alias data from a JSON string.
|
|
///
|
|
/// Accepts either a bare JSON array of `ProviderAliases` objects or an
|
|
/// ARM-style `{ "value": [...] }` envelope, as produced by
|
|
/// `Get-AzPolicyAlias` or the ARM provider metadata API.
|
|
///
|
|
/// Multiple calls accumulate data; duplicates overwrite earlier entries.
|
|
pub fn load_from_json(&mut self, json: &str) -> Result<()> {
|
|
let providers: Vec<ProviderAliases> = types::load_auto(json)?;
|
|
for provider in providers {
|
|
self.load_provider(provider);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Load alias data from a data policy manifest JSON string.
|
|
///
|
|
/// Data policy manifests describe data-plane aliases (e.g.,
|
|
/// `Microsoft.KeyVault.Data`, `Microsoft.DataFactory.Data`). Their format
|
|
/// differs from the control-plane `ProviderAliases` catalog: aliases use
|
|
/// `paths[0].path` instead of `defaultPath`, and may include both
|
|
/// top-level aliases and per-resource-type groups.
|
|
///
|
|
/// Multiple calls accumulate data; duplicates overwrite earlier entries.
|
|
pub fn load_data_policy_manifest_json(&mut self, json: &str) -> Result<()> {
|
|
let manifest: DataPolicyManifest = serde_json::from_str(json)?;
|
|
self.load_data_policy_manifest(manifest);
|
|
Ok(())
|
|
}
|
|
|
|
/// Load a data policy manifest.
|
|
pub fn load_data_policy_manifest(&mut self, manifest: DataPolicyManifest) {
|
|
let namespace = &manifest.data_namespace;
|
|
|
|
// Collect known resource types so we can assign top-level aliases.
|
|
let known_rts: Vec<String> = manifest
|
|
.resource_type_aliases
|
|
.iter()
|
|
.map(|rta| rta.resource_type.clone())
|
|
.collect();
|
|
|
|
// 1. Process per-resource-type alias groups.
|
|
for rta in &manifest.resource_type_aliases {
|
|
let fq_type = alloc::format!("{}/{}", namespace, rta.resource_type);
|
|
let entries = convert_data_manifest_aliases(&fq_type, &rta.aliases);
|
|
self.ingest_alias_entries(&fq_type, &entries);
|
|
}
|
|
|
|
// 2. Process top-level aliases — determine resource type from name.
|
|
// Group them by resource type, then merge into existing entries.
|
|
let mut grouped: BTreeMap<String, Vec<AliasEntry>> = BTreeMap::new();
|
|
let ns_prefix = alloc::format!("{}/", namespace);
|
|
|
|
for alias in &manifest.aliases {
|
|
let suffix = if alias.name.len() > ns_prefix.len()
|
|
&& alias.name[..ns_prefix.len()].eq_ignore_ascii_case(&ns_prefix)
|
|
{
|
|
&alias.name[ns_prefix.len()..]
|
|
} else {
|
|
continue;
|
|
};
|
|
|
|
// Find the longest matching known resource type.
|
|
let mut best_rt: Option<&str> = None;
|
|
let mut best_len = 0;
|
|
for rt in &known_rts {
|
|
if suffix.len() > rt.len()
|
|
&& suffix[..rt.len()].eq_ignore_ascii_case(rt)
|
|
&& suffix.as_bytes().get(rt.len()) == Some(&b'/')
|
|
&& rt.len() > best_len
|
|
{
|
|
best_rt = Some(rt.as_str());
|
|
best_len = rt.len();
|
|
}
|
|
}
|
|
|
|
let entry = data_alias_to_alias_entry(alias);
|
|
|
|
if let Some(rt) = best_rt {
|
|
let fq_type = alloc::format!("{}/{}", namespace, rt);
|
|
grouped.entry(fq_type).or_default().push(entry);
|
|
}
|
|
// If no matching resource type, skip (shouldn't happen in practice).
|
|
}
|
|
|
|
// Merge grouped top-level aliases into existing resource type entries.
|
|
for (fq_type, entries) in &grouped {
|
|
self.ingest_alias_entries(fq_type, entries);
|
|
}
|
|
}
|
|
|
|
/// Load a single provider's alias data.
|
|
pub fn load_provider(&mut self, provider: ProviderAliases) {
|
|
let namespace = &provider.namespace;
|
|
for rt in provider.resource_types {
|
|
let fq_type = alloc::format!("{}/{}", namespace, rt.resource_type);
|
|
self.ingest_alias_entries(&fq_type, &rt.aliases);
|
|
}
|
|
}
|
|
|
|
/// Ingest a batch of alias entries for a fully-qualified resource type.
|
|
///
|
|
/// This is the shared core used by both [`load_provider`] and
|
|
/// [`load_data_policy_manifest`]. It updates the global lookup maps
|
|
/// and merges resolved entries into the per-resource-type map.
|
|
fn ingest_alias_entries(&mut self, fq_type: &str, aliases: &[AliasEntry]) {
|
|
let prefix = alloc::format!("{}/", fq_type);
|
|
|
|
for alias in aliases {
|
|
// Skip aliases without a default_path — the normalizer's
|
|
// resolve_resource_type also skips these, so inserting them into
|
|
// compiler maps would cause a divergence where the compiler
|
|
// resolves the alias but normalized input never contains the field.
|
|
if alias.default_path.is_none() {
|
|
continue;
|
|
}
|
|
|
|
// Derive the short name by stripping the resource type prefix.
|
|
let raw_short = if alias.name.len() > prefix.len()
|
|
&& alias
|
|
.name
|
|
.get(..prefix.len())
|
|
.is_some_and(|s| s.eq_ignore_ascii_case(&prefix))
|
|
{
|
|
// Both slice boundaries are valid: prefix is ASCII
|
|
// (resource type + '/'), so if `..prefix.len()` succeeded
|
|
// above, `prefix.len()..` is guaranteed to be on a char
|
|
// boundary too. The `unwrap_or` is a defensive fallback
|
|
// that can never trigger for well-formed Azure alias names.
|
|
alias
|
|
.name
|
|
.get(prefix.len()..)
|
|
.unwrap_or(&alias.name)
|
|
.to_string()
|
|
} else if let Some(rest) = alias
|
|
.name
|
|
.rfind('/')
|
|
.and_then(|idx| alias.name.get(idx.saturating_add(1)..))
|
|
{
|
|
rest.to_string()
|
|
} else {
|
|
continue;
|
|
};
|
|
|
|
let default_path = alias.default_path.as_deref().unwrap_or("");
|
|
// The normalizer flattens `properties` into the resource root, so
|
|
// strip a leading `properties.` / `properties/` from the short name.
|
|
let raw_short_normalized = normalize_short_name(&raw_short).to_string();
|
|
|
|
let short = if is_root_field_collision(&raw_short_normalized, default_path) {
|
|
collision_safe_key(&raw_short_normalized)
|
|
} else {
|
|
raw_short_normalized
|
|
};
|
|
let lc_name = alias.name.to_lowercase();
|
|
self.alias_to_short.insert(lc_name.clone(), short);
|
|
let is_modifiable = types::has_flag(
|
|
alias
|
|
.default_metadata
|
|
.as_ref()
|
|
.and_then(|m| m.attributes.as_deref()),
|
|
"Modifiable",
|
|
);
|
|
self.alias_modifiable.insert(lc_name, is_modifiable);
|
|
}
|
|
|
|
let resolved = resolve_resource_type(fq_type, aliases);
|
|
let lc_type = fq_type.to_lowercase();
|
|
|
|
// Merge into existing entry if one exists (for data manifests that
|
|
// have both top-level and per-resource-type aliases for the same type).
|
|
if let Some(existing) = self.types.get_mut(&lc_type) {
|
|
for (key, entry) in resolved.entries {
|
|
existing.entries.insert(key, entry);
|
|
}
|
|
// Replace sub-resource arrays: redetect from the merged entry set
|
|
// so that overwritten aliases that no longer indicate sub-resource
|
|
// wrapping cause stale classifications to be removed.
|
|
existing.sub_resource_arrays = redetect_sub_resource_arrays(&existing.entries);
|
|
// Recompute aggregates after merge.
|
|
let (default_agg, versioned_agg) =
|
|
precompute_aggregates(&existing.entries, &existing.sub_resource_arrays);
|
|
existing.default_aggregates = default_agg;
|
|
existing.versioned_aggregates = versioned_agg;
|
|
} else {
|
|
self.types.insert(lc_type, resolved);
|
|
}
|
|
}
|
|
|
|
/// Look up resolved aliases for a resource type.
|
|
///
|
|
/// The lookup is case-insensitive.
|
|
pub fn get(&self, resource_type: &str) -> Option<&ResolvedAliases> {
|
|
self.types.get(&resource_type.to_lowercase())
|
|
}
|
|
|
|
/// Number of registered resource types.
|
|
pub fn len(&self) -> usize {
|
|
self.types.len()
|
|
}
|
|
|
|
/// Whether the registry is empty.
|
|
pub fn is_empty(&self) -> bool {
|
|
self.types.is_empty()
|
|
}
|
|
|
|
/// Resolve a fully-qualified alias name to its short name.
|
|
///
|
|
/// The lookup is case-insensitive. Returns `None` if the alias is not
|
|
/// found in the registry (meaning it's either already a short name or
|
|
/// not a known alias).
|
|
pub fn resolve_alias(&self, fq_name: &str) -> Option<&str> {
|
|
self.alias_to_short
|
|
.get(&fq_name.to_lowercase())
|
|
.map(String::as_str)
|
|
}
|
|
|
|
/// Return a reference to the alias-to-short-name map.
|
|
///
|
|
/// Keys are lowercase fully-qualified alias names; values are short names.
|
|
pub const fn alias_map(&self) -> &BTreeMap<String, String> {
|
|
&self.alias_to_short
|
|
}
|
|
|
|
/// Return a reference to the alias-to-modifiable map.
|
|
///
|
|
/// Keys are lowercase fully-qualified alias names; values are `true` when
|
|
/// the alias has `defaultMetadata.attributes = "Modifiable"`.
|
|
pub const fn alias_modifiable_map(&self) -> &BTreeMap<String, bool> {
|
|
&self.alias_modifiable
|
|
}
|
|
|
|
/// Normalize a raw ARM resource and wrap it in the input envelope.
|
|
///
|
|
/// Convenience method that combines alias lookup, normalization, and
|
|
/// envelope construction. The resource type is extracted from the
|
|
/// `type` field of `arm_resource` automatically.
|
|
///
|
|
/// # Arguments
|
|
///
|
|
/// * `arm_resource` — The raw ARM JSON for the resource.
|
|
/// * `api_version` — Optional API version to select versioned alias paths.
|
|
/// * `context` — Optional context object for the input envelope.
|
|
/// * `parameters` — Optional parameters object for the input envelope.
|
|
pub fn normalize_and_wrap(
|
|
&self,
|
|
arm_resource: &crate::Value,
|
|
api_version: Option<&str>,
|
|
context: Option<crate::Value>,
|
|
parameters: Option<crate::Value>,
|
|
) -> crate::Value {
|
|
let normalized = normalizer::normalize(arm_resource, Some(self), api_version);
|
|
normalizer::build_input_envelope(normalized, context, parameters)
|
|
}
|
|
|
|
/// Denormalize a normalized resource back to ARM JSON structure.
|
|
///
|
|
/// Convenience method that combines alias lookup and denormalization.
|
|
/// The resource type is extracted from the `type` field of `normalized`
|
|
/// automatically.
|
|
///
|
|
/// # Arguments
|
|
///
|
|
/// * `normalized` — The normalized JSON object (as produced by
|
|
/// [`normalizer::normalize`]).
|
|
/// * `api_version` — Optional API version to select versioned alias paths.
|
|
pub fn denormalize(
|
|
&self,
|
|
normalized: &crate::Value,
|
|
api_version: Option<&str>,
|
|
) -> crate::Value {
|
|
denormalizer::denormalize(normalized, Some(self), api_version)
|
|
}
|
|
}
|
|
|
|
/// Resolve a resource type's alias entries into `ResolvedAliases`.
|
|
///
|
|
/// This:
|
|
/// 1. Strips the resource type prefix from alias names to produce short names.
|
|
/// 2. Extracts `defaultPath` and versioned paths.
|
|
/// 3. Detects sub-resource arrays from alias path patterns.
|
|
fn resolve_resource_type(fq_type: &str, aliases: &[types::AliasEntry]) -> ResolvedAliases {
|
|
let prefix = alloc::format!("{}/", fq_type);
|
|
let mut entries = BTreeMap::new();
|
|
let mut sub_resource_arrays: BTreeSet<String> = BTreeSet::new();
|
|
|
|
for alias in aliases {
|
|
// Derive short name by stripping the resource type prefix.
|
|
// For cross-type aliases (e.g., Microsoft.Compute/imagePublisher
|
|
// under the virtualMachines resource type), the name does not
|
|
// start with the resource type prefix. In that case, take the
|
|
// part after the last '/'.
|
|
let short_name = if alias.name.len() > prefix.len()
|
|
&& alias.name[..prefix.len()].eq_ignore_ascii_case(&prefix)
|
|
{
|
|
&alias.name[prefix.len()..]
|
|
} else if let Some(rest) = alias
|
|
.name
|
|
.rfind('/')
|
|
.and_then(|idx| alias.name.get(idx.saturating_add(1)..))
|
|
{
|
|
rest
|
|
} else {
|
|
&alias.name
|
|
};
|
|
|
|
// The normalizer flattens `properties` into the resource root, so
|
|
// strip a leading `properties.` / `properties/` from the short name.
|
|
let short_name = normalize_short_name(short_name);
|
|
|
|
let default_path = match &alias.default_path {
|
|
Some(p) => p.clone(),
|
|
None => continue, // Skip aliases without a default path (shouldn't happen in production)
|
|
};
|
|
|
|
// Detect sub-resource arrays from the alias pattern.
|
|
// If short name contains `[*]` and default_path has
|
|
// `properties.X[*].properties.Y`, then X is a sub-resource array.
|
|
detect_sub_resource_array(short_name, &default_path, &mut sub_resource_arrays);
|
|
|
|
let versioned_paths: Vec<(String, String)> = alias
|
|
.paths
|
|
.iter()
|
|
.flat_map(|p| {
|
|
p.api_versions
|
|
.iter()
|
|
.map(move |v| (v.clone(), p.path.clone()))
|
|
})
|
|
.collect();
|
|
|
|
entries.insert(
|
|
short_name.to_lowercase(),
|
|
ResolvedEntry::new(
|
|
short_name.to_string(),
|
|
default_path,
|
|
versioned_paths,
|
|
alias.default_metadata.clone(),
|
|
),
|
|
);
|
|
}
|
|
|
|
// Precompute aggregate fields from entries.
|
|
let (default_aggregates, versioned_aggregates) =
|
|
precompute_aggregates(&entries, &sub_resource_arrays);
|
|
|
|
ResolvedAliases {
|
|
resource_type: fq_type.to_string(),
|
|
entries,
|
|
sub_resource_arrays,
|
|
default_aggregates,
|
|
versioned_aggregates,
|
|
}
|
|
}
|
|
|
|
/// Return type for [`precompute_aggregates`].
|
|
type AggregateFields = (VersionedAggregates, BTreeMap<String, VersionedAggregates>);
|
|
|
|
/// Precompute element remaps, reverse remaps, and array renames from resolved
|
|
/// entries for the default path AND every distinct api_version found in any
|
|
/// entry's `versioned_paths`.
|
|
///
|
|
/// This moves O(aliases) string splitting/lowercasing out of the per-call
|
|
/// normalize/denormalize hot path into a one-time cost at registry-load time.
|
|
fn precompute_aggregates(
|
|
entries: &BTreeMap<String, ResolvedEntry>,
|
|
sub_resource_arrays: &BTreeSet<String>,
|
|
) -> AggregateFields {
|
|
// Collect all distinct api_versions that appear in any entry.
|
|
let mut all_versions = alloc::collections::BTreeSet::new();
|
|
for entry in entries.values() {
|
|
for (ver, _) in &entry.versioned_paths {
|
|
all_versions.insert(ver.to_lowercase());
|
|
}
|
|
}
|
|
|
|
// Compute default aggregates (api_version = None).
|
|
let default_agg = compute_aggregates_for_version(entries, sub_resource_arrays, None);
|
|
|
|
// Compute per-version aggregates.
|
|
let mut versioned_map = BTreeMap::new();
|
|
for ver in &all_versions {
|
|
let agg = compute_aggregates_for_version(entries, sub_resource_arrays, Some(ver.as_str()));
|
|
// Only store if it differs from default (saves memory for versions
|
|
// where no entry has a different path).
|
|
if agg != default_agg {
|
|
versioned_map.insert(ver.clone(), agg);
|
|
}
|
|
}
|
|
|
|
(default_agg, versioned_map)
|
|
}
|
|
|
|
/// Compute aggregates for a specific api_version (or None for default path).
|
|
fn compute_aggregates_for_version(
|
|
entries: &BTreeMap<String, ResolvedEntry>,
|
|
sub_resource_arrays: &BTreeSet<String>,
|
|
api_version: Option<&str>,
|
|
) -> VersionedAggregates {
|
|
let mut element_remaps = Vec::new();
|
|
let mut reverse_element_remaps = Vec::new();
|
|
let mut renames_norm: Vec<(String, String)> = Vec::new();
|
|
let mut renames_denorm: Vec<(String, String)> = Vec::new();
|
|
|
|
// Use BTreeSet for O(log N) dedup instead of Vec::contains.
|
|
let mut seen_norm = alloc::collections::BTreeSet::new();
|
|
let mut seen_denorm = alloc::collections::BTreeSet::new();
|
|
|
|
for entry in entries.values() {
|
|
if !entry.is_wildcard {
|
|
continue;
|
|
}
|
|
|
|
// Skip sub-resource array root entries for scalar processing.
|
|
// The set is pre-lowercased, so use a direct O(log n) lookup.
|
|
if sub_resource_arrays.contains(&entry.short_name.to_ascii_lowercase()) {
|
|
continue;
|
|
}
|
|
|
|
// Select the ARM path for this version (or default).
|
|
let selected_path = entry.select_path(api_version);
|
|
let short_parts: Vec<&str> = entry.short_name.split("[*].").collect();
|
|
let arm_raw_parts: Vec<&str> = selected_path.split("[*].").collect();
|
|
|
|
if short_parts.len() >= 2 && arm_raw_parts.len() >= 2 {
|
|
if let (Some(short_leaf), Some(arm_leaf_raw)) =
|
|
(short_parts.last(), arm_raw_parts.last())
|
|
{
|
|
let arm_leaf = arm_leaf_raw
|
|
.strip_prefix("properties.")
|
|
.unwrap_or(arm_leaf_raw);
|
|
|
|
if !short_leaf.eq_ignore_ascii_case(arm_leaf) {
|
|
let array_chain: Vec<Vec<String>> = short_parts
|
|
.split_last()
|
|
.map(|(_, init)| init)
|
|
.unwrap_or_default()
|
|
.iter()
|
|
.map(|part| part.split('.').map(|s| s.to_ascii_lowercase()).collect())
|
|
.collect();
|
|
|
|
let source_lc = arm_leaf.to_ascii_lowercase();
|
|
let target_lc = short_leaf.to_ascii_lowercase();
|
|
|
|
element_remaps.push(PrecomputedRemap {
|
|
array_chain: array_chain.clone(),
|
|
source_field: source_lc.clone(),
|
|
target_field: target_lc.clone(),
|
|
});
|
|
|
|
reverse_element_remaps.push(PrecomputedReverseRemap {
|
|
array_chain: array_chain.clone(),
|
|
source_field: target_lc.clone(),
|
|
target_field: source_lc,
|
|
cleanup_field: target_lc,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Compute array base rename.
|
|
if let (Some(short_base), Some(arm_base)) = (
|
|
entry.short_name.split("[*]").next(),
|
|
selected_path.split("[*]").next(),
|
|
) {
|
|
let arm_base_stripped = arm_base.strip_prefix("properties.").unwrap_or(arm_base);
|
|
if !short_base.eq_ignore_ascii_case(arm_base_stripped) {
|
|
// Normalize direction: (arm_base_lc, short_base_lc)
|
|
let norm_pair = (
|
|
arm_base_stripped.to_ascii_lowercase(),
|
|
short_base.to_ascii_lowercase(),
|
|
);
|
|
if seen_norm.insert(norm_pair.clone()) {
|
|
renames_norm.push(norm_pair);
|
|
}
|
|
|
|
// Denormalize direction: (short_base_lc, arm_base)
|
|
let denorm_pair = (
|
|
short_base.to_ascii_lowercase(),
|
|
arm_base_stripped.to_string(),
|
|
);
|
|
if seen_denorm.insert(denorm_pair.clone()) {
|
|
renames_denorm.push(denorm_pair);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
VersionedAggregates {
|
|
element_remaps,
|
|
reverse_element_remaps,
|
|
array_renames_normalize: renames_norm,
|
|
array_renames_denormalize: renames_denorm,
|
|
}
|
|
}
|
|
|
|
/// Detect sub-resource arrays from alias naming patterns.
|
|
///
|
|
/// If the alias short name has `X[*].Y` and the default path has
|
|
/// `properties.X[*].properties.Y`, then `X` is a sub-resource array whose
|
|
/// elements need `properties` flattening during normalization.
|
|
///
|
|
/// For nested sub-resource arrays like `X[*].Y[*].Z` mapping to
|
|
/// `properties.X[*].properties.Y[*].properties.Z`, both `X` and `X.Y`
|
|
/// (dotted path within the normalized structure) are sub-resource arrays.
|
|
fn detect_sub_resource_array(
|
|
short_name: &str,
|
|
default_path: &str,
|
|
sub_resource_arrays: &mut BTreeSet<String>,
|
|
) {
|
|
// Split short name and default path by `[*].`
|
|
let short_parts: Vec<&str> = short_name.split("[*].").collect();
|
|
let path_parts: Vec<&str> = default_path.split("[*].").collect();
|
|
|
|
if short_parts.len() < 2 || path_parts.len() < 2 {
|
|
return; // No wildcard — not a sub-resource array alias
|
|
}
|
|
|
|
// For each `[*]` level, check if the pattern matches sub-resource wrapping.
|
|
// short_name: securityRules[*].protocol
|
|
// default_path: properties.securityRules[*].properties.protocol
|
|
//
|
|
// The first segment of the default_path after splitting should start with
|
|
// "properties." and the part after the first [*]. should start with
|
|
// "properties." to indicate sub-resource wrapping.
|
|
|
|
// Build up the chain of sub-resource array names.
|
|
let mut accumulated_name = String::new();
|
|
|
|
for (i, array_field) in short_parts
|
|
.iter()
|
|
.enumerate()
|
|
.take(short_parts.len().saturating_sub(1))
|
|
{
|
|
// For the first level, check that default_path starts with `properties.X[*].properties.`
|
|
// For nested levels, the segment after [*]. should also start with `properties.`
|
|
if let Some(next_path_segment) = path_parts.get(i.saturating_add(1)) {
|
|
if next_path_segment.starts_with("properties.")
|
|
|| next_path_segment.starts_with("properties/")
|
|
{
|
|
// This is a sub-resource array!
|
|
let name = if accumulated_name.is_empty() {
|
|
array_field.to_string()
|
|
} else {
|
|
alloc::format!("{}.{}", accumulated_name, array_field)
|
|
};
|
|
|
|
sub_resource_arrays.insert(name.to_ascii_lowercase());
|
|
accumulated_name = name;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Redetect sub-resource arrays from the merged entry set.
|
|
///
|
|
/// This is used after merging alias entries to ensure that stale sub-resource
|
|
/// classifications are removed when overwritten aliases no longer indicate
|
|
/// sub-resource wrapping.
|
|
fn redetect_sub_resource_arrays(entries: &BTreeMap<String, ResolvedEntry>) -> BTreeSet<String> {
|
|
let mut sub_resource_arrays = BTreeSet::new();
|
|
for entry in entries.values() {
|
|
detect_sub_resource_array(
|
|
&entry.short_name,
|
|
&entry.default_path,
|
|
&mut sub_resource_arrays,
|
|
);
|
|
}
|
|
sub_resource_arrays
|
|
}
|
|
|
|
/// Convert a data manifest alias to a standard [`AliasEntry`].
|
|
///
|
|
/// Data manifest aliases use `paths[0].path` as the effective default path
|
|
/// (the `defaultPath` field is absent). Both `apiVersions` and
|
|
/// `schemaVersions` are treated as version sets for versioned path lookup.
|
|
fn data_alias_to_alias_entry(dma: &types::DataManifestAlias) -> AliasEntry {
|
|
let default_path = dma.paths.first().map(|p| p.path.clone());
|
|
|
|
let paths: Vec<AliasPath> = dma
|
|
.paths
|
|
.iter()
|
|
.map(|p| {
|
|
// Merge apiVersions and schemaVersions into a single version list.
|
|
let mut versions = p.api_versions.clone();
|
|
versions.extend(p.schema_versions.iter().cloned());
|
|
AliasPath {
|
|
path: p.path.clone(),
|
|
api_versions: versions,
|
|
metadata: None,
|
|
..Default::default()
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
AliasEntry {
|
|
name: dma.name.clone(),
|
|
default_path,
|
|
default_metadata: None,
|
|
paths,
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
/// Convert a slice of data manifest aliases to standard [`AliasEntry`] objects.
|
|
fn convert_data_manifest_aliases(
|
|
_fq_type: &str,
|
|
aliases: &[types::DataManifestAlias],
|
|
) -> Vec<AliasEntry> {
|
|
aliases.iter().map(data_alias_to_alias_entry).collect()
|
|
}
|
|
|
|
/// Normalize a short name derived from an alias FQ name.
|
|
///
|
|
/// The normalizer always flattens `properties` into the resource root, so a
|
|
/// short name like `properties.domainNames[*]` must be reduced to
|
|
/// `domainNames[*]` to match the normalized resource structure.
|
|
///
|
|
/// This is primarily needed for data-plane aliases where the FQ name includes
|
|
/// `properties.` in the property part (e.g.,
|
|
/// `Microsoft.DataFactory.Data/factories/outboundTraffic/properties.domainNames[*]`).
|
|
/// Control-plane alias names never include `properties.` in the short portion.
|
|
fn normalize_short_name(short: &str) -> &str {
|
|
short
|
|
.strip_prefix("properties.")
|
|
.or_else(|| short.strip_prefix("properties/"))
|
|
.unwrap_or(short)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[allow(clippy::indexing_slicing, clippy::unwrap_used, clippy::expect_used)]
|
|
mod tests {
|
|
use alloc::string::ToString as _;
|
|
use alloc::vec;
|
|
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_detect_sub_resource_nsg() {
|
|
let mut subs = BTreeSet::new();
|
|
detect_sub_resource_array(
|
|
"securityRules[*].protocol",
|
|
"properties.securityRules[*].properties.protocol",
|
|
&mut subs,
|
|
);
|
|
assert_eq!(subs, BTreeSet::from(["securityrules".to_string()]));
|
|
}
|
|
|
|
#[test]
|
|
fn test_detect_no_sub_resource() {
|
|
let mut subs = BTreeSet::new();
|
|
// ipRules[*].value -> properties.networkAcls.ipRules[*].value
|
|
// No `properties.` after the `[*].` means NOT a sub-resource
|
|
detect_sub_resource_array(
|
|
"networkAcls.ipRules[*].value",
|
|
"properties.networkAcls.ipRules[*].value",
|
|
&mut subs,
|
|
);
|
|
assert!(subs.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_detect_nested_sub_resource() {
|
|
let mut subs = BTreeSet::new();
|
|
detect_sub_resource_array(
|
|
"subnets[*].ipConfigurations[*].name",
|
|
"properties.subnets[*].properties.ipConfigurations[*].properties.name",
|
|
&mut subs,
|
|
);
|
|
let expected: BTreeSet<String> = BTreeSet::from([
|
|
"subnets".to_string(),
|
|
"subnets.ipconfigurations".to_string(),
|
|
]);
|
|
assert_eq!(subs, expected);
|
|
}
|
|
|
|
#[test]
|
|
fn test_resolve_resource_type_basic() {
|
|
let aliases = vec![
|
|
types::AliasEntry {
|
|
name: "Microsoft.Storage/storageAccounts/supportsHttpsTrafficOnly".to_string(),
|
|
default_path: Some("properties.supportsHttpsTrafficOnly".to_string()),
|
|
default_metadata: None,
|
|
paths: vec![],
|
|
..Default::default()
|
|
},
|
|
types::AliasEntry {
|
|
name: "Microsoft.Storage/storageAccounts/sku.name".to_string(),
|
|
default_path: Some("sku.name".to_string()),
|
|
default_metadata: None,
|
|
paths: vec![],
|
|
..Default::default()
|
|
},
|
|
];
|
|
|
|
let resolved = resolve_resource_type("Microsoft.Storage/storageAccounts", &aliases);
|
|
assert_eq!(resolved.entries.len(), 2);
|
|
|
|
let https_entry = resolved.entries.get("supportshttpstrafficonly").unwrap();
|
|
assert_eq!(
|
|
https_entry.default_path,
|
|
"properties.supportsHttpsTrafficOnly"
|
|
);
|
|
|
|
let sku_entry = resolved.entries.get("sku.name").unwrap();
|
|
assert_eq!(sku_entry.default_path, "sku.name");
|
|
|
|
assert!(resolved.sub_resource_arrays.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_resolve_nsg_has_sub_resource_arrays() {
|
|
let aliases = vec![
|
|
types::AliasEntry {
|
|
name: "Microsoft.Network/networkSecurityGroups/securityRules[*].protocol"
|
|
.to_string(),
|
|
default_path: Some("properties.securityRules[*].properties.protocol".to_string()),
|
|
default_metadata: None,
|
|
paths: vec![],
|
|
..Default::default()
|
|
},
|
|
types::AliasEntry {
|
|
name: "Microsoft.Network/networkSecurityGroups/securityRules[*].access".to_string(),
|
|
default_path: Some("properties.securityRules[*].properties.access".to_string()),
|
|
default_metadata: None,
|
|
paths: vec![],
|
|
..Default::default()
|
|
},
|
|
];
|
|
|
|
let resolved = resolve_resource_type("Microsoft.Network/networkSecurityGroups", &aliases);
|
|
assert_eq!(
|
|
resolved.sub_resource_arrays,
|
|
BTreeSet::from(["securityrules".to_string()])
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_load_from_json() {
|
|
let json = r#"[
|
|
{
|
|
"namespace": "Microsoft.Storage",
|
|
"resourceTypes": [
|
|
{
|
|
"resourceType": "storageAccounts",
|
|
"aliases": [
|
|
{
|
|
"name": "Microsoft.Storage/storageAccounts/supportsHttpsTrafficOnly",
|
|
"defaultPath": "properties.supportsHttpsTrafficOnly",
|
|
"paths": []
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
]"#;
|
|
|
|
let mut registry = AliasRegistry::new();
|
|
registry.load_from_json(json).unwrap();
|
|
assert_eq!(registry.len(), 1);
|
|
|
|
let resolved = registry.get("Microsoft.Storage/storageAccounts").unwrap();
|
|
assert_eq!(resolved.entries.len(), 1);
|
|
assert!(resolved.entries.contains_key("supportshttpstrafficonly"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_registry_case_insensitive_get() {
|
|
let json = r#"[
|
|
{
|
|
"namespace": "Microsoft.Storage",
|
|
"resourceTypes": [
|
|
{
|
|
"resourceType": "storageAccounts",
|
|
"aliases": [
|
|
{
|
|
"name": "Microsoft.Storage/storageAccounts/supportsHttpsTrafficOnly",
|
|
"defaultPath": "properties.supportsHttpsTrafficOnly",
|
|
"paths": []
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
]"#;
|
|
|
|
let mut registry = AliasRegistry::new();
|
|
registry.load_from_json(json).unwrap();
|
|
|
|
// Mixed-case lookup should work
|
|
assert!(registry.get("microsoft.storage/STORAGEACCOUNTS").is_some());
|
|
assert!(registry.get("MICROSOFT.STORAGE/storageAccounts").is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn test_resolve_with_versioned_paths() {
|
|
let aliases = vec![types::AliasEntry {
|
|
name: "Microsoft.Web/sites/siteConfig.numberOfWorkers".to_string(),
|
|
default_path: Some("properties.siteConfig.numberOfWorkers".to_string()),
|
|
default_metadata: None,
|
|
paths: vec![
|
|
types::AliasPath {
|
|
path: "properties.siteConfig.properties.numberOfWorkers".to_string(),
|
|
api_versions: vec!["2014-04-01".to_string(), "2014-06-01".to_string()],
|
|
metadata: None,
|
|
..Default::default()
|
|
},
|
|
types::AliasPath {
|
|
path: "properties.siteConfig.numberOfWorkers".to_string(),
|
|
api_versions: vec!["2021-01-01".to_string()],
|
|
metadata: None,
|
|
..Default::default()
|
|
},
|
|
],
|
|
..Default::default()
|
|
}];
|
|
|
|
let resolved = resolve_resource_type("Microsoft.Web/sites", &aliases);
|
|
let entry = resolved.entries.get("siteconfig.numberofworkers").unwrap();
|
|
|
|
assert_eq!(entry.default_path, "properties.siteConfig.numberOfWorkers");
|
|
// Has versioned paths
|
|
assert_eq!(entry.versioned_paths.len(), 3); // 2 + 1
|
|
assert_eq!(
|
|
entry.select_path(Some("2014-04-01")),
|
|
"properties.siteConfig.properties.numberOfWorkers"
|
|
);
|
|
assert_eq!(
|
|
entry.select_path(Some("2021-01-01")),
|
|
"properties.siteConfig.numberOfWorkers"
|
|
);
|
|
// Unknown version falls back to default
|
|
assert_eq!(
|
|
entry.select_path(Some("9999-01-01")),
|
|
"properties.siteConfig.numberOfWorkers"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_resolve_alias_without_default_path_skipped() {
|
|
let aliases = vec![
|
|
types::AliasEntry {
|
|
name: "Microsoft.Storage/storageAccounts/good".to_string(),
|
|
default_path: Some("properties.good".to_string()),
|
|
default_metadata: None,
|
|
paths: vec![],
|
|
..Default::default()
|
|
},
|
|
types::AliasEntry {
|
|
name: "Microsoft.Storage/storageAccounts/bad".to_string(),
|
|
default_path: None,
|
|
default_metadata: None,
|
|
paths: vec![],
|
|
..Default::default()
|
|
},
|
|
];
|
|
|
|
let resolved = resolve_resource_type("Microsoft.Storage/storageAccounts", &aliases);
|
|
assert_eq!(resolved.entries.len(), 1);
|
|
assert!(resolved.entries.contains_key("good"));
|
|
assert!(!resolved.entries.contains_key("bad"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_empty_registry() {
|
|
let registry = AliasRegistry::new();
|
|
assert!(registry.is_empty());
|
|
assert_eq!(registry.len(), 0);
|
|
assert!(registry.get("Microsoft.Storage/storageAccounts").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_resolve_empty_aliases() {
|
|
let resolved = resolve_resource_type("Microsoft.Test/empty", &[]);
|
|
assert!(resolved.entries.is_empty());
|
|
assert!(resolved.sub_resource_arrays.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_sub_resource_dedup() {
|
|
// Multiple aliases for the same sub-resource array should deduplicate
|
|
let aliases = vec![
|
|
types::AliasEntry {
|
|
name: "T/R/rules[*].a".to_string(),
|
|
default_path: Some("properties.rules[*].properties.a".to_string()),
|
|
default_metadata: None,
|
|
paths: vec![],
|
|
..Default::default()
|
|
},
|
|
types::AliasEntry {
|
|
name: "T/R/rules[*].b".to_string(),
|
|
default_path: Some("properties.rules[*].properties.b".to_string()),
|
|
default_metadata: None,
|
|
paths: vec![],
|
|
..Default::default()
|
|
},
|
|
types::AliasEntry {
|
|
name: "T/R/rules[*].c".to_string(),
|
|
default_path: Some("properties.rules[*].properties.c".to_string()),
|
|
default_metadata: None,
|
|
paths: vec![],
|
|
..Default::default()
|
|
},
|
|
];
|
|
|
|
let resolved = resolve_resource_type("T/R", &aliases);
|
|
// "rules" should appear only once despite 3 aliases detecting it
|
|
assert_eq!(
|
|
resolved.sub_resource_arrays,
|
|
BTreeSet::from(["rules".to_string()])
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_normalize_and_wrap_full_pipeline() {
|
|
let json = r#"[
|
|
{
|
|
"namespace": "Microsoft.Network",
|
|
"resourceTypes": [
|
|
{
|
|
"resourceType": "networkSecurityGroups",
|
|
"aliases": [
|
|
{
|
|
"name": "Microsoft.Network/networkSecurityGroups/securityRules[*].protocol",
|
|
"defaultPath": "properties.securityRules[*].properties.protocol",
|
|
"paths": []
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
]"#;
|
|
|
|
let mut registry = AliasRegistry::new();
|
|
registry.load_from_json(json).unwrap();
|
|
|
|
let arm_resource = crate::Value::from_json_str(
|
|
r#"{
|
|
"name": "myNsg",
|
|
"type": "Microsoft.Network/networkSecurityGroups",
|
|
"properties": {
|
|
"securityRules": [
|
|
{
|
|
"name": "rule1",
|
|
"properties": {
|
|
"protocol": "Tcp"
|
|
}
|
|
}
|
|
]
|
|
}
|
|
}"#,
|
|
)
|
|
.unwrap();
|
|
|
|
let context = crate::Value::from_json_str(r#"{"resourceGroup": {"name": "rg1"}}"#).unwrap();
|
|
let parameters = crate::Value::from_json_str(r#"{"env": "prod"}"#).unwrap();
|
|
|
|
let envelope =
|
|
registry.normalize_and_wrap(&arm_resource, None, Some(context), Some(parameters));
|
|
|
|
// Resource is normalized (all keys lowercased)
|
|
assert_eq!(envelope["resource"]["name"], crate::Value::from("myNsg"));
|
|
let rules = envelope["resource"]["securityrules"].as_array().unwrap();
|
|
assert_eq!(rules[0]["protocol"], crate::Value::from("Tcp"));
|
|
assert_eq!(rules[0]["properties"], crate::Value::Undefined);
|
|
// Context and parameters are passed through
|
|
assert_eq!(
|
|
envelope["context"]["resourceGroup"]["name"],
|
|
crate::Value::from("rg1")
|
|
);
|
|
assert_eq!(envelope["parameters"]["env"], crate::Value::from("prod"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_load_test_aliases_json() {
|
|
// Integration test: load the actual test_aliases.json file
|
|
let json = std::fs::read_to_string("tests/azure_policy/aliases/test_aliases.json")
|
|
.expect("test_aliases.json should exist");
|
|
|
|
let mut registry = AliasRegistry::new();
|
|
registry
|
|
.load_from_json(&json)
|
|
.expect("test_aliases.json should parse");
|
|
|
|
// Expect 44 resource types
|
|
assert_eq!(registry.len(), 44);
|
|
|
|
// Storage
|
|
let storage = registry
|
|
.get("Microsoft.Storage/storageAccounts")
|
|
.expect("Storage aliases should exist");
|
|
assert!(!storage.entries.is_empty());
|
|
assert!(storage.sub_resource_arrays.is_empty());
|
|
|
|
// NSG — should have sub-resource arrays
|
|
let nsg = registry
|
|
.get("Microsoft.Network/networkSecurityGroups")
|
|
.expect("NSG aliases should exist");
|
|
assert!(!nsg.entries.is_empty());
|
|
assert!(
|
|
nsg.sub_resource_arrays.contains("securityrules"),
|
|
"NSG should detect securityRules as sub-resource array"
|
|
);
|
|
|
|
// KeyVault
|
|
assert!(registry.get("Microsoft.KeyVault/vaults").is_some());
|
|
|
|
// SQL
|
|
assert!(registry.get("Microsoft.Sql/servers").is_some());
|
|
|
|
// VM
|
|
assert!(registry.get("Microsoft.Compute/virtualMachines").is_some());
|
|
|
|
// Web
|
|
assert!(registry.get("Microsoft.Web/sites").is_some());
|
|
|
|
// AKS
|
|
assert!(registry
|
|
.get("Microsoft.ContainerService/managedClusters")
|
|
.is_some());
|
|
|
|
// Disks
|
|
assert!(registry.get("Microsoft.Compute/disks").is_some());
|
|
|
|
// NIC — should have sub-resource arrays
|
|
let nic = registry
|
|
.get("Microsoft.Network/networkInterfaces")
|
|
.expect("NIC aliases should exist");
|
|
assert!(
|
|
nic.sub_resource_arrays.contains("ipconfigurations"),
|
|
"NIC should detect ipConfigurations as sub-resource array"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_resolve_alias_basic() {
|
|
let json = r#"[
|
|
{
|
|
"namespace": "Microsoft.Storage",
|
|
"resourceTypes": [
|
|
{
|
|
"resourceType": "storageAccounts",
|
|
"aliases": [
|
|
{
|
|
"name": "Microsoft.Storage/storageAccounts/supportsHttpsTrafficOnly",
|
|
"defaultPath": "properties.supportsHttpsTrafficOnly",
|
|
"paths": []
|
|
},
|
|
{
|
|
"name": "Microsoft.Storage/storageAccounts/sku.name",
|
|
"defaultPath": "sku.name",
|
|
"paths": []
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
]"#;
|
|
|
|
let mut registry = AliasRegistry::new();
|
|
registry.load_from_json(json).unwrap();
|
|
|
|
assert_eq!(
|
|
registry.resolve_alias("Microsoft.Storage/storageAccounts/supportsHttpsTrafficOnly"),
|
|
Some("supportsHttpsTrafficOnly")
|
|
);
|
|
assert_eq!(
|
|
registry.resolve_alias("Microsoft.Storage/storageAccounts/sku.name"),
|
|
Some("sku.name")
|
|
);
|
|
// Case-insensitive
|
|
assert_eq!(
|
|
registry.resolve_alias("microsoft.storage/STORAGEACCOUNTS/supportsHttpsTrafficOnly"),
|
|
Some("supportsHttpsTrafficOnly")
|
|
);
|
|
// Unknown alias
|
|
assert_eq!(
|
|
registry.resolve_alias("Microsoft.Storage/storageAccounts/unknown"),
|
|
None
|
|
);
|
|
// Already a short name
|
|
assert_eq!(registry.resolve_alias("supportsHttpsTrafficOnly"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn test_alias_map_for_compiler() {
|
|
let json = r#"[
|
|
{
|
|
"namespace": "Microsoft.Network",
|
|
"resourceTypes": [
|
|
{
|
|
"resourceType": "networkSecurityGroups",
|
|
"aliases": [
|
|
{
|
|
"name": "Microsoft.Network/networkSecurityGroups/securityRules[*].protocol",
|
|
"defaultPath": "properties.securityRules[*].properties.protocol",
|
|
"paths": []
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
]"#;
|
|
|
|
let mut registry = AliasRegistry::new();
|
|
registry.load_from_json(json).unwrap();
|
|
|
|
let map = registry.alias_map();
|
|
// alias_map returns the short name derived by stripping the FQ type
|
|
// prefix from the alias name:
|
|
// `Microsoft.Network/networkSecurityGroups/securityRules[*].protocol`
|
|
// → `securityRules[*].protocol`
|
|
assert_eq!(
|
|
map.get("microsoft.network/networksecuritygroups/securityrules[*].protocol"),
|
|
Some(&"securityRules[*].protocol".to_string())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_alias_modifiable_comma_separated_flags() {
|
|
// Regression: alias_modifiable_map should detect "Modifiable" even when
|
|
// the attributes field contains comma-separated flags like
|
|
// "Modifiable, SupportsCreate".
|
|
let json = r#"[
|
|
{
|
|
"namespace": "Microsoft.Test",
|
|
"resourceTypes": [
|
|
{
|
|
"resourceType": "widgets",
|
|
"aliases": [
|
|
{
|
|
"name": "Microsoft.Test/widgets/singleFlag",
|
|
"defaultPath": "properties.singleFlag",
|
|
"paths": [],
|
|
"defaultMetadata": {
|
|
"attributes": "Modifiable"
|
|
}
|
|
},
|
|
{
|
|
"name": "Microsoft.Test/widgets/multiFlag",
|
|
"defaultPath": "properties.multiFlag",
|
|
"paths": [],
|
|
"defaultMetadata": {
|
|
"attributes": "Modifiable, SupportsCreate"
|
|
}
|
|
},
|
|
{
|
|
"name": "Microsoft.Test/widgets/notModifiable",
|
|
"defaultPath": "properties.notModifiable",
|
|
"paths": [],
|
|
"defaultMetadata": {
|
|
"attributes": "None"
|
|
}
|
|
},
|
|
{
|
|
"name": "Microsoft.Test/widgets/noMetadata",
|
|
"defaultPath": "properties.noMetadata",
|
|
"paths": []
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
]"#;
|
|
|
|
let mut registry = AliasRegistry::new();
|
|
registry.load_from_json(json).unwrap();
|
|
|
|
let modifiable = registry.alias_modifiable_map();
|
|
|
|
// Single "Modifiable" flag → true
|
|
assert_eq!(
|
|
modifiable.get("microsoft.test/widgets/singleflag"),
|
|
Some(&true)
|
|
);
|
|
// Comma-separated flags containing "Modifiable" → true
|
|
assert_eq!(
|
|
modifiable.get("microsoft.test/widgets/multiflag"),
|
|
Some(&true)
|
|
);
|
|
// "None" → false
|
|
assert_eq!(
|
|
modifiable.get("microsoft.test/widgets/notmodifiable"),
|
|
Some(&false)
|
|
);
|
|
// No metadata → false
|
|
assert_eq!(
|
|
modifiable.get("microsoft.test/widgets/nometadata"),
|
|
Some(&false)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_overwrite_removes_stale_sub_resource_arrays() {
|
|
// Regression: a second load that overwrites aliases for the same
|
|
// resource type must remove stale sub-resource array classifications
|
|
// when the new alias paths no longer indicate sub-resource wrapping.
|
|
|
|
// First load: rules[*] aliases with `properties.` wrapping → sub-resource.
|
|
let json1 = r#"[
|
|
{
|
|
"namespace": "Microsoft.Test",
|
|
"resourceTypes": [
|
|
{
|
|
"resourceType": "firewalls",
|
|
"aliases": [
|
|
{
|
|
"name": "Microsoft.Test/firewalls/rules[*].protocol",
|
|
"defaultPath": "properties.rules[*].properties.protocol",
|
|
"paths": []
|
|
},
|
|
{
|
|
"name": "Microsoft.Test/firewalls/rules[*].port",
|
|
"defaultPath": "properties.rules[*].properties.port",
|
|
"paths": []
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
]"#;
|
|
|
|
let mut registry = AliasRegistry::new();
|
|
registry.load_from_json(json1).unwrap();
|
|
|
|
let fw = registry.get("Microsoft.Test/firewalls").unwrap();
|
|
assert!(
|
|
fw.sub_resource_arrays.contains("rules"),
|
|
"first load should detect 'rules' as a sub-resource array"
|
|
);
|
|
|
|
// Second load: same resource type, but rules[*] no longer has inner
|
|
// `properties.` wrapping → NOT a sub-resource.
|
|
let json2 = r#"[
|
|
{
|
|
"namespace": "Microsoft.Test",
|
|
"resourceTypes": [
|
|
{
|
|
"resourceType": "firewalls",
|
|
"aliases": [
|
|
{
|
|
"name": "Microsoft.Test/firewalls/rules[*].protocol",
|
|
"defaultPath": "properties.rules[*].protocol",
|
|
"paths": []
|
|
},
|
|
{
|
|
"name": "Microsoft.Test/firewalls/rules[*].port",
|
|
"defaultPath": "properties.rules[*].port",
|
|
"paths": []
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
]"#;
|
|
|
|
registry.load_from_json(json2).unwrap();
|
|
|
|
let fw2 = registry.get("Microsoft.Test/firewalls").unwrap();
|
|
assert!(
|
|
!fw2.sub_resource_arrays.contains("rules"),
|
|
"after overwrite, 'rules' should no longer be a sub-resource array"
|
|
);
|
|
}
|
|
}
|