From a1045a7ca228a2a09597e2dd133d932c80539823 Mon Sep 17 00:00:00 2001 From: Jerome Brown Date: Tue, 8 Sep 2026 15:45:51 +1200 Subject: [PATCH 1/3] fix: Add typed CIDR placeholders for unresolved address prefixes When address prefixes are allocated at deployment time, such as by Azure Virtual Network Manager IPAM pools, `reference()` cannot resolve them during Bicep expansion. The empty result was then passed to `cidrHost()` and `cidrSubnet()`, which failed with "The specified CIDR '' is not valid". Add a source-aware placeholder table keyed on resource type and normalized property path, so unresolved properties return a typed mock value instead of an empty one. Placeholders use RFC 5737 TEST-NET-1 (192.0.2.0/24) so they are obvious in output and cannot collide with real address space. Covered today: - `Microsoft.Network/virtualNetworks` - `addressSpace.addressPrefixes` - `Microsoft.Network/virtualNetworks/subnets` - `addressPrefix`, `addressPrefixes` - `Microsoft.Network/networkManagers/ipamPools` - `addressPrefixes` The table is the extension point, so additional resource properties can be added without further changes to the expansion code. The `cidr*()` functions are deliberately left strict. Only indexed access into a placeholder array yields a CIDR string, so genuine authoring mistakes, such as passing `id` or an unindexed `addressPrefixes`, still fail as before. Fixes #3907 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/changelog.md | 3 + .../Arm/Deployments/TemplateContext.cs | 45 ++++- .../Arm/Expressions/Functions.cs | 26 ++- src/PSRule.Rules.Azure/Arm/Mocks/Mock.cs | 185 +++++++++++++++++- .../Arm/Symbols/ArrayDeploymentSymbol.cs | 28 ++- .../Arm/Symbols/IDeploymentSymbol.cs | 2 + .../Arm/Symbols/ObjectDeploymentSymbol.cs | 8 + .../Arm/Expressions/FunctionTests.cs | 109 +++++++++++ 8 files changed, 388 insertions(+), 18 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index 1cf953eb6c8..a3dd706c327 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -41,6 +41,9 @@ What's changed since pre-release v1.48.0-B0228: - Container Registry: - Deprecated `Azure.ACR.GeoReplica` because ACR zone redundancy is automatic in supported regions. [#3846](https://github.com/Azure/PSRule.Rules.Azure/issues/3846) +- Bug fixes: + - Fixed `cidrHost` and `cidrSubnet` failing on unresolved virtual network, subnet, and IPAM pool address prefixes by @oWretch. + [#3907](https://github.com/Azure/PSRule.Rules.Azure/issues/3907) - Engineering: - Bump YamlDotNet to 11.2.5. diff --git a/src/PSRule.Rules.Azure/Arm/Deployments/TemplateContext.cs b/src/PSRule.Rules.Azure/Arm/Deployments/TemplateContext.cs index a2bc1bf340d..53ad12eed3c 100644 --- a/src/PSRule.Rules.Azure/Arm/Deployments/TemplateContext.cs +++ b/src/PSRule.Rules.Azure/Arm/Deployments/TemplateContext.cs @@ -219,12 +219,33 @@ public bool TryGetResource(string nameOrResourceId, out IResourceValue? resource return false; var resourceId = nameOrResourceId; + IResourceValue? symbolResource = null; if (_Symbols.TryGetValue(nameOrResourceId, out var symbol) && symbol != null) - resourceId = symbol.GetId(0); + { + symbol.TryGetResource(0, out symbolResource); + + // The ID of an existing resource is expanded on demand and may not be resolvable. + try + { + resourceId = symbol.GetId(0); + } + catch + { + resourceId = null; + } + } if (resourceId != null && _ResourceIds.TryGetValue(resourceId, out resource)) return true; + // Fall back to the resource attached to the symbol. Existing resources are tracked as symbols + // but are not added as deployable resources, so they are not in the resource ID lookup. + if (symbolResource != null) + { + resource = symbolResource; + return true; + } + // Recurse search for resource in the parent deployment by original resource ID only. if (Parent != null && ResourceHelper.IsResourceId(nameOrResourceId) && Parent.TryGetResource(nameOrResourceId, out resource)) return true; @@ -240,11 +261,27 @@ public bool TryGetResourceCollection(string symbolicName, out IResourceValue[]? return false; var ids = array.GetIds(); - resources = new IResourceValue[ids.Length]; + var byId = new IResourceValue[ids.Length]; + var resolved = true; for (var i = 0; i < ids.Length; i++) - resources[i] = _ResourceIds[ids[i]]; + { + if (ids[i] == null || !_ResourceIds.TryGetValue(ids[i], out var item)) + { + resolved = false; + break; + } + byId[i] = item; + } - return true; + if (resolved) + { + resources = byId; + return true; + } + + // Fall back to the resources attached to the symbol for existing resources. + resources = array.GetResources(); + return resources.Length > 0; } #nullable restore diff --git a/src/PSRule.Rules.Azure/Arm/Expressions/Functions.cs b/src/PSRule.Rules.Azure/Arm/Expressions/Functions.cs index 3006703ec38..9b35655ab83 100644 --- a/src/PSRule.Rules.Azure/Arm/Expressions/Functions.cs +++ b/src/PSRule.Rules.Azure/Arm/Expressions/Functions.cs @@ -1176,10 +1176,13 @@ private static object GetReferenceResult(IResourceValue resource, bool full) return full ? deployment : deployment.Properties; if (resource.Existing && !resource.Value.TryGetProperty(PROPERTY_PROPERTIES, out _)) - return full ? new Mock.MockResource(resource.Id) : new Mock.MockResource(resource.Id)[PROPERTY_PROPERTIES]; + { + var mockResourceId = GetResourceIdOrSymbolicName(resource); + return full ? new Mock.MockResource(mockResourceId, resource.Type) : new Mock.MockResource(mockResourceId, resource.Type)[PROPERTY_PROPERTIES]; + } if (!full && resource.Value.TryGetProperty(PROPERTY_PROPERTIES, out var properties)) - return new Mock.MockObject(properties); + return new Mock.MockResourceObject(properties, GetResourceIdOrSymbolicName(resource), resource.Type); return new Mock.MockObject(full ? resource.Value : new JObject()); } @@ -2736,11 +2739,28 @@ private static bool TryResourceIdOrSymbolicName(ITemplateContext context, string resourceId = resourceIdOrSymbolicName; if (context.TryGetResource(resourceIdOrSymbolicName, out var resource) && resource != null) - resourceId = resource.Id; + resourceId = GetResourceIdOrSymbolicName(resource); return resourceId != null; } + /// + /// Get the resource ID of a resource, falling back to the symbolic name. + /// The ID of an existing resource is expanded on demand and may not be resolvable, for example when + /// the scope of the resource depends on a value that is not known during expansion. + /// + private static string GetResourceIdOrSymbolicName(IResourceValue resource) + { + try + { + return resource.Id; + } + catch + { + return resource.SymbolicName; + } + } + private static int Compare(object left, object right) { if (ExpressionHelpers.TryLong(left, out var longLeft) && ExpressionHelpers.TryLong(right, out var longRight)) diff --git a/src/PSRule.Rules.Azure/Arm/Mocks/Mock.cs b/src/PSRule.Rules.Azure/Arm/Mocks/Mock.cs index 7cc520e9130..78a9c0717b7 100644 --- a/src/PSRule.Rules.Azure/Arm/Mocks/Mock.cs +++ b/src/PSRule.Rules.Azure/Arm/Mocks/Mock.cs @@ -12,6 +12,52 @@ namespace PSRule.Rules.Azure.Arm.Mocks; internal sealed class Mock { + private const string RESOURCE_TYPE_VIRTUAL_NETWORK = "Microsoft.Network/virtualNetworks"; + private const string RESOURCE_TYPE_SUBNET = "Microsoft.Network/virtualNetworks/subnets"; + private const string RESOURCE_TYPE_IPAM_POOL = "Microsoft.Network/networkManagers/ipamPools"; + private const string PATH_VIRTUAL_NETWORK_ADDRESS_PREFIXES = "addressSpace.addressPrefixes"; + private const string PATH_SUBNET_ADDRESS_PREFIX = "addressPrefix"; + private const string PATH_SUBNET_ADDRESS_PREFIXES = "addressPrefixes"; + private const string PLACEHOLDER_VIRTUAL_NETWORK_CIDR = "192.0.2.0/24"; + private const string PLACEHOLDER_SUBNET_CIDR = "192.0.2.0/28"; + + private static readonly MockPlaceholderDescriptor[] _ResourcePlaceholders = + [ + new(RESOURCE_TYPE_VIRTUAL_NETWORK, PATH_VIRTUAL_NETWORK_ADDRESS_PREFIXES, MockPlaceholderKind.Array, [PLACEHOLDER_VIRTUAL_NETWORK_CIDR]), + new(RESOURCE_TYPE_SUBNET, PATH_SUBNET_ADDRESS_PREFIX, MockPlaceholderKind.String, [PLACEHOLDER_SUBNET_CIDR]), + new(RESOURCE_TYPE_SUBNET, PATH_SUBNET_ADDRESS_PREFIXES, MockPlaceholderKind.Array, [PLACEHOLDER_SUBNET_CIDR]), + new(RESOURCE_TYPE_IPAM_POOL, PATH_SUBNET_ADDRESS_PREFIXES, MockPlaceholderKind.Array, [PLACEHOLDER_VIRTUAL_NETWORK_CIDR]), + ]; + + private enum MockPlaceholderKind + { + String, + Array + } + + private sealed class MockPlaceholderDescriptor(string resourceType, string propertyPath, MockPlaceholderKind kind, string[] values) + { + public string ResourceType { get; } = resourceType; + + public string PropertyPath { get; } = propertyPath; + + public MockPlaceholderKind Kind { get; } = kind; + + private string[] Values { get; } = values; + + public bool TryGetValue(bool secret, out MockValue value) + { + value = Values.Length > 0 ? new MockValue(Values[0], secret) : null!; + return Values.Length > 0; + } + + public void AddValues(JArray array, bool secret) + { + for (var i = 0; i < Values.Length; i++) + array.Add(new MockValue(Values[i], secret)); + } + } + /// /// Mock an unknown property or value. /// @@ -79,10 +125,14 @@ public override string ToString() internal sealed class MockResource : MockUnknownObject { public MockResource(string resourceId) + : this(resourceId, resourceType: null) { } + + public MockResource(string resourceId, string? resourceType) : base() { ResourceId = resourceId; - ResourceHelper.TryResourceIdComponents(resourceId, out var subscriptionId, out var resourceGroupName, out string? resourceType, out string? name); + ResourceHelper.TryResourceIdComponents(resourceId, out var subscriptionId, out var resourceGroupName, out string? resourceIdType, out string? name); + ResourceType = resourceType ?? resourceIdType ?? string.Empty; if (resourceId != null) { Add("id", new JValue(resourceId)); @@ -93,9 +143,9 @@ public MockResource(string resourceId) Add("subscriptionId", new JValue(subscriptionId)); } - if (resourceType != null) + if (ResourceType.Length > 0) { - Add("type", new JValue(resourceType)); + Add("type", new JValue(ResourceType)); } if (name != null) @@ -103,12 +153,14 @@ public MockResource(string resourceId) Add("name", new JValue(name)); } - var properties = new MockResourceProperties(resourceId ?? string.Empty); + var properties = new MockResourceProperties(resourceId ?? string.Empty, ResourceType); Add("properties", properties); } public string ResourceId { get; } + public string ResourceType { get; } + public override JToken? this[object key] { get => base[key]; set => base[key] = value; } public override string GetString() @@ -125,58 +177,142 @@ internal interface IMockResourceCollection internal sealed class MockResourceProperties : MockUnknownObject { private readonly string _ResourceId; + private readonly string _ResourceType; - public MockResourceProperties(string resourceId) + public MockResourceProperties(string resourceId, string resourceType) : base() { _ResourceId = resourceId; + _ResourceType = resourceType; } protected override JToken CreateUnknownProperty(object key) { - return key is string propertyName ? new MockResourceProperty(_ResourceId, propertyName, IsSecret) : base.CreateUnknownProperty(key); + return key is string propertyName ? CreateResourceProperty(_ResourceId, _ResourceType, propertyName, propertyName, IsSecret) : base.CreateUnknownProperty(key); + } + } + + /// + /// The properties of a resource that is known, but may only define a subset of its properties. + /// Concrete properties are returned as-is, while known address properties that were not defined in the + /// source resolve to a typed placeholder instead of an unknown value. + /// + internal sealed class MockResourceObject : MockObject + { + private readonly string _ResourceId; + private readonly string _ResourceType; + + public MockResourceObject(JObject value, string resourceId, string resourceType) + : base(value) + { + _ResourceId = resourceId; + _ResourceType = resourceType; + } + + protected override JToken CreateUnknownProperty(object key) + { + return key is string propertyName ? CreateResourceProperty(_ResourceId, _ResourceType, propertyName, propertyName, IsSecret) : base.CreateUnknownProperty(key); } } internal sealed class MockResourceProperty : MockUnknownObject, IMockResourceCollection { private readonly string _ResourceId; + private readonly string _ResourceType; private readonly string _PropertyName; + private readonly string _PropertyPath; private MockResourcePropertyArray? _Array; - public MockResourceProperty(string resourceId, string propertyName, bool secret) + public MockResourceProperty(string resourceId, string resourceType, string propertyName, bool secret) + : this(resourceId, resourceType, propertyName, propertyName, secret) { } + + internal MockResourceProperty(string resourceId, string resourceType, string propertyName, string propertyPath, bool secret) : base(secret) { _ResourceId = resourceId; + _ResourceType = resourceType; _PropertyName = propertyName; + _PropertyPath = propertyPath; } public override JToken? GetValue(TypePrimitive type) { + if (type == TypePrimitive.Array && TryGetPlaceholder(out var descriptor) && descriptor.Kind == MockPlaceholderKind.Array) + return ToArray(); + return type == TypePrimitive.Array ? ToArray() : base.GetValue(type); } + public override JToken? GetValue(object key) + { + key = GetBaseObject(key); + if (TryGetPlaceholder(out var descriptor)) + { + if (descriptor.Kind == MockPlaceholderKind.Array && key is int) + return ToArray().GetValue(key); + } + return base.GetValue(key); + } + + public override TValue? GetValue() where TValue : default + { + if (typeof(TValue) == typeof(string) && + TryGetPlaceholder(out var descriptor) && + descriptor.Kind == MockPlaceholderKind.String && + descriptor.TryGetValue(IsSecret, out var value) && + value.Value() is TValue result) + return result; + + return base.GetValue(); + } + private MockResourcePropertyArray ToArray() { - return _Array ??= new MockResourcePropertyArray(_ResourceId, _PropertyName, IsSecret); + return _Array ??= new MockResourcePropertyArray(_ResourceId, _ResourceType, _PropertyName, _PropertyPath, IsSecret); } public MockResourcePropertyItem CreateItem() { return new MockResourcePropertyItem(_ResourceId, _PropertyName, IsSecret); } + + protected override JToken CreateUnknownProperty(object key) + { + return key is string propertyName ? CreateResourceProperty(_ResourceId, _ResourceType, propertyName, string.Concat(_PropertyPath, ".", propertyName), IsSecret) : base.CreateUnknownProperty(key); + } + + private bool TryGetPlaceholder(out MockPlaceholderDescriptor descriptor) + { + return TryResourcePlaceholder(_ResourceType, _PropertyPath, out descriptor); + } } internal sealed class MockResourcePropertyArray : MockArray, IMockResourceCollection { private readonly string _ResourceId; + private readonly string _ResourceType; private readonly string _PropertyName; + private readonly string _PropertyPath; - public MockResourcePropertyArray(string resourceId, string propertyName, bool secret) + public MockResourcePropertyArray(string resourceId, string resourceType, string propertyName, string propertyPath, bool secret) : base(secret) { _ResourceId = resourceId; + _ResourceType = resourceType; _PropertyName = propertyName; + _PropertyPath = propertyPath; + + if (TryResourcePlaceholder(_ResourceType, _PropertyPath, out var descriptor) && descriptor.Kind == MockPlaceholderKind.Array) + descriptor.AddValues(this, secret); + } + + public override JToken? GetValue(object key) + { + key = GetBaseObject(key); + if (key is int && TryResourcePlaceholder(_ResourceType, _PropertyPath, out var descriptor) && descriptor.TryGetValue(IsSecret, out var value)) + return value; + + return base.GetValue(key); } public MockResourcePropertyItem CreateItem() @@ -359,7 +495,7 @@ public MockArray(bool secret = false) return type == TypePrimitive.None || type == TypePrimitive.Array ? this : null; } - public JToken? GetValue(object key) + public virtual JToken? GetValue(object key) { if (key is long l) key = (int)l; @@ -638,6 +774,35 @@ private static TypePrimitive GetTypePrimitive(JToken token) throw new NotImplementedException(); } + private static JToken CreateResourceProperty(string resourceId, string resourceType, string propertyName, string propertyPath, bool secret) + { + if (TryResourcePlaceholder(resourceType, propertyPath, out var descriptor)) + { + if (descriptor.Kind == MockPlaceholderKind.Array) + return new MockResourcePropertyArray(resourceId, resourceType, propertyName, propertyPath, secret); + + if (descriptor.TryGetValue(secret, out var value)) + return value; + } + return new MockResourceProperty(resourceId, resourceType, propertyName, propertyPath, secret); + } + + private static bool TryResourcePlaceholder(string resourceType, string propertyPath, out MockPlaceholderDescriptor descriptor) + { + descriptor = null!; + for (var i = 0; i < _ResourcePlaceholders.Length; i++) + { + var placeholder = _ResourcePlaceholders[i]; + if (StringComparer.OrdinalIgnoreCase.Equals(resourceType, placeholder.ResourceType) && + StringComparer.OrdinalIgnoreCase.Equals(propertyPath, placeholder.PropertyPath)) + { + descriptor = placeholder; + return true; + } + } + return false; + } + private static bool TryWellKnownStringProperty(JObject o, string key, out JValue? value) { value = default; diff --git a/src/PSRule.Rules.Azure/Arm/Symbols/ArrayDeploymentSymbol.cs b/src/PSRule.Rules.Azure/Arm/Symbols/ArrayDeploymentSymbol.cs index abf9cafa342..f3436a0777f 100644 --- a/src/PSRule.Rules.Azure/Arm/Symbols/ArrayDeploymentSymbol.cs +++ b/src/PSRule.Rules.Azure/Arm/Symbols/ArrayDeploymentSymbol.cs @@ -15,13 +15,16 @@ namespace PSRule.Rules.Azure.Arm.Symbols; internal sealed class ArrayDeploymentSymbol(string name) : DeploymentSymbol(name), IDeploymentSymbol { private List? _Ids; + private List? _Resources; public DeploymentSymbolKind Kind => DeploymentSymbolKind.Array; public void Configure(IResourceValue resource) { + _Resources ??= []; + _Resources.Add(resource); _Ids ??= []; - _Ids.Add(resource.Id); + _Ids.Add(TryResourceId(resource)); } public string? GetId(int index) @@ -29,10 +32,33 @@ public void Configure(IResourceValue resource) return _Ids?[index]; } + public bool TryGetResource(int index, out IResourceValue? resource) + { + resource = _Resources != null && index >= 0 && index < _Resources.Count ? _Resources[index] : null; + return resource != null; + } + public string[] GetIds() { return _Ids?.ToArray() ?? []; } + + public IResourceValue[] GetResources() + { + return _Resources?.ToArray() ?? []; + } + + private static string TryResourceId(IResourceValue resource) + { + try + { + return resource.Id; + } + catch + { + return resource.SymbolicName; + } + } } #nullable restore diff --git a/src/PSRule.Rules.Azure/Arm/Symbols/IDeploymentSymbol.cs b/src/PSRule.Rules.Azure/Arm/Symbols/IDeploymentSymbol.cs index 1987306577e..00d7af8e9dc 100644 --- a/src/PSRule.Rules.Azure/Arm/Symbols/IDeploymentSymbol.cs +++ b/src/PSRule.Rules.Azure/Arm/Symbols/IDeploymentSymbol.cs @@ -16,6 +16,8 @@ internal interface IDeploymentSymbol void Configure(IResourceValue r); string? GetId(int index); + + bool TryGetResource(int index, out IResourceValue? resource); } #nullable restore diff --git a/src/PSRule.Rules.Azure/Arm/Symbols/ObjectDeploymentSymbol.cs b/src/PSRule.Rules.Azure/Arm/Symbols/ObjectDeploymentSymbol.cs index eb1ffbc7285..0d9908b45b7 100644 --- a/src/PSRule.Rules.Azure/Arm/Symbols/ObjectDeploymentSymbol.cs +++ b/src/PSRule.Rules.Azure/Arm/Symbols/ObjectDeploymentSymbol.cs @@ -14,6 +14,7 @@ namespace PSRule.Rules.Azure.Arm.Symbols; internal sealed class ObjectDeploymentSymbol : DeploymentSymbol, IDeploymentSymbol { private Func? _GetId; + private IResourceValue? _Resource; public ObjectDeploymentSymbol(string name, IResourceValue? resource) : base(name) @@ -26,6 +27,7 @@ public ObjectDeploymentSymbol(string name, IResourceValue? resource) public void Configure(IResourceValue resource) { + _Resource = resource; _GetId = () => resource.Id; } @@ -33,6 +35,12 @@ public void Configure(IResourceValue resource) { return _GetId == null ? null : _GetId(); } + + public bool TryGetResource(int index, out IResourceValue? resource) + { + resource = _Resource; + return resource != null; + } } #nullable restore diff --git a/tests/PSRule.Rules.Azure.Tests/Arm/Expressions/FunctionTests.cs b/tests/PSRule.Rules.Azure.Tests/Arm/Expressions/FunctionTests.cs index 44bb0da5750..7543589fda6 100644 --- a/tests/PSRule.Rules.Azure.Tests/Arm/Expressions/FunctionTests.cs +++ b/tests/PSRule.Rules.Azure.Tests/Arm/Expressions/FunctionTests.cs @@ -921,6 +921,115 @@ public void Reference() Assert.Equal("a", actual["name"].Value()); } + [Fact] + [Trait(TRAIT, TRAIT_RESOURCE)] + public void ReferenceWithNetworkAddressPlaceholders() + { + var context = GetContext(); + var vnetId = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-test/providers/Microsoft.Network/virtualNetworks/vnet-001"; + var subnetId = string.Concat(vnetId, "/subnets/subnet-001"); + + var vnet = Functions.Reference(context, [vnetId]) as Mock.MockObject; + Assert.NotNull(vnet); + var vnetPrefixes = vnet["addressSpace"]["addressPrefixes"] as Mock.MockArray; + Assert.NotNull(vnetPrefixes); + Assert.Equal("192.0.2.0/24", vnetPrefixes[0].Value()); + Assert.Equal("192.0.2.4", Functions.CidrHost(context, [vnetPrefixes[0], 3]) as string); + Assert.Throws(() => Functions.CidrHost(context, [vnetPrefixes, 3])); + + var subnet = Functions.Reference(context, [subnetId]) as Mock.MockObject; + Assert.NotNull(subnet); + Assert.Equal("192.0.2.0/28", subnet["addressPrefix"].Value()); + var subnetPrefixes = subnet["addressPrefixes"] as Mock.MockArray; + Assert.NotNull(subnetPrefixes); + Assert.Equal("192.0.2.0/28", subnetPrefixes[0].Value()); + Assert.Equal("192.0.2.4", Functions.CidrHost(context, [subnetPrefixes[0], 3]) as string); + + var subnetFull = Functions.Reference(context, [subnetId, "2025-07-01", "Full"]) as Mock.MockObject; + Assert.NotNull(subnetFull); + Assert.Throws(() => Functions.CidrHost(context, [subnetFull["id"], 3])); + } + + [Fact] + [Trait(TRAIT, TRAIT_RESOURCE)] + public void ReferenceWithExistingNetworkAddressSymbols() + { + var context = GetContext(); + var vnet = new ExistingResourceValue(context, "Microsoft.Network/virtualNetworks", "vnet", JObject.Parse(@" +{ + ""name"": ""vnet-001"", + ""properties"": { + ""ipamPoolPrefixAllocations"": [] + } +}"), null); + var subnet = new ExistingResourceValue(context, "Microsoft.Network/virtualNetworks/subnets", "vnet::subnet", JObject.Parse(@" +{ + ""name"": ""subnet-001"", + ""properties"": { + ""ipamPoolPrefixAllocations"": [] + } +}"), null); + var pool = new ExistingResourceValue(context, "Microsoft.Network/networkManagers/ipamPools", "networkManager::platformPool", JObject.Parse(@" +{ + ""name"": ""platformPool"" +}"), null); + context.AddSymbol(DeploymentSymbol.NewObject("vnet", vnet)); + context.AddSymbol(DeploymentSymbol.NewObject("vnet::subnet", subnet)); + context.AddSymbol(DeploymentSymbol.NewObject("networkManager::platformPool", pool)); + + var vnetProperties = Functions.Reference(context, ["vnet"]) as Mock.MockObject; + Assert.NotNull(vnetProperties); + Assert.Equal("192.0.2.0/24", vnetProperties["addressSpace"]["addressPrefixes"][0].Value()); + + var subnetProperties = Functions.Reference(context, ["vnet::subnet"]) as Mock.MockObject; + Assert.NotNull(subnetProperties); + Assert.Equal("192.0.2.0/28", subnetProperties["addressPrefixes"][0].Value()); + Assert.Equal("192.0.2.4", Functions.CidrHost(context, [subnetProperties["addressPrefixes"][0], 3]) as string); + + var poolProperties = Functions.Reference(context, ["networkManager::platformPool"]) as Mock.MockObject; + Assert.NotNull(poolProperties); + Assert.Equal("192.0.2.0/24", poolProperties["addressPrefixes"][0].Value()); + Assert.Equal("192.0.2.0/23", Functions.CidrSubnet(context, [poolProperties["addressPrefixes"][0], 23, 0]) as string); + + var subnetResourceId = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-test/providers/Microsoft.Network/virtualNetworks/vnet-001/subnets/ApplicationGatewaySubnet"; + var deployedSubnet = new ResourceValue(subnetResourceId, "ApplicationGatewaySubnet", "Microsoft.Network/virtualNetworks/subnets", "vnet::deployedSubnet", JObject.Parse(@" +{ + ""properties"": { + ""ipamPoolPrefixAllocations"": [] + } +}"), null); + context.AddResource(deployedSubnet); + context.AddSymbol(DeploymentSymbol.NewObject("vnet::deployedSubnet", deployedSubnet)); + + var deployedSubnetProperties = Functions.Reference(context, ["vnet::deployedSubnet"]) as Mock.MockObject; + Assert.NotNull(deployedSubnetProperties); + Assert.Equal("192.0.2.0/28", deployedSubnetProperties["addressPrefixes"][0].Value()); + Assert.Equal("192.0.2.4", Functions.CidrHost(context, [deployedSubnetProperties["addressPrefixes"][0], 3]) as string); + } + + [Fact] + [Trait(TRAIT, TRAIT_RESOURCE)] + public void ReferenceUsesConcreteNetworkAddressProperties() + { + var context = GetContext(); + var resourceId = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-test/providers/Microsoft.Network/virtualNetworks/vnet-001"; + var resource = new ResourceValue(resourceId, "vnet-001", "Microsoft.Network/virtualNetworks", "vnet", JObject.Parse(@" +{ + ""properties"": { + ""addressSpace"": { + ""addressPrefixes"": [ + ""203.0.113.0/24"" + ] + } + } +}"), null); + context.AddResource(resource); + + var actual = Functions.Reference(context, [resourceId]) as Mock.MockObject; + Assert.NotNull(actual); + Assert.Equal("203.0.113.0/24", actual["addressSpace"]["addressPrefixes"][0].Value()); + } + [Fact] [Trait(TRAIT, TRAIT_RESOURCE)] public void References() From e05ff8af4abb60c5f50d7c4494b3e43ff7adb11c Mon Sep 17 00:00:00 2001 From: Jerome Brown Date: Wed, 9 Sep 2026 11:23:40 +1200 Subject: [PATCH 2/3] fix: Add ARM expression compatibility fix for tryGet on arrays --- docs/changelog.md | 2 ++ src/PSRule.Rules.Azure/Arm/Expressions/ExpressionHelpers.cs | 2 +- tests/PSRule.Rules.Azure.Tests/Arm/Expressions/FunctionTests.cs | 1 + 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/changelog.md b/docs/changelog.md index a3dd706c327..183a98f9258 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -44,6 +44,8 @@ What's changed since pre-release v1.48.0-B0228: - Bug fixes: - Fixed `cidrHost` and `cidrSubnet` failing on unresolved virtual network, subnet, and IPAM pool address prefixes by @oWretch. [#3907](https://github.com/Azure/PSRule.Rules.Azure/issues/3907) + - Fixed `tryGet` throwing instead of returning `null` for a property lookup against an array by @oWretch. + [#3907](https://github.com/Azure/PSRule.Rules.Azure/issues/3907) - Engineering: - Bump YamlDotNet to 11.2.5. diff --git a/src/PSRule.Rules.Azure/Arm/Expressions/ExpressionHelpers.cs b/src/PSRule.Rules.Azure/Arm/Expressions/ExpressionHelpers.cs index 03445481935..9c496f1a7c0 100644 --- a/src/PSRule.Rules.Azure/Arm/Expressions/ExpressionHelpers.cs +++ b/src/PSRule.Rules.Azure/Arm/Expressions/ExpressionHelpers.cs @@ -236,7 +236,7 @@ internal static bool TryPropertyOrField(object o, string propertyName, out objec return true; } - if (o is JToken jToken && o is not JValue) + if (o is JToken jToken && o is not JValue && o is not JArray) { var propertyToken = jToken[propertyName]; if (propertyToken == null) diff --git a/tests/PSRule.Rules.Azure.Tests/Arm/Expressions/FunctionTests.cs b/tests/PSRule.Rules.Azure.Tests/Arm/Expressions/FunctionTests.cs index 7543589fda6..69c6b761970 100644 --- a/tests/PSRule.Rules.Azure.Tests/Arm/Expressions/FunctionTests.cs +++ b/tests/PSRule.Rules.Azure.Tests/Arm/Expressions/FunctionTests.cs @@ -752,6 +752,7 @@ public void TryGet() }; Assert.Equal("two", (Functions.TryGet(context, [testObject2, 1]) as JValue).Value()); + Assert.Null(Functions.TryGet(context, [testObject2, "addressPrefixes"])); Assert.Throws(() => Functions.TryGet(context, null)); Assert.Throws(() => Functions.TryGet(context, [])); From db8af86fec325e6d2b34ad71febfdf8e1cfb6b93 Mon Sep 17 00:00:00 2001 From: Jerome Brown Date: Wed, 16 Sep 2026 11:12:52 +1200 Subject: [PATCH 3/3] fix: resolve existing resource symbolic reference to malformed mock Cross-scope `existing` resources referenced via symbolic name (languageVersion 2.0) were resolved to a malformed mock because they were only registered via context.AddSymbol() and never via context.AddResource(), so TemplateContext.TryGetResource fell through to a fallback that used the raw unresolved symbolic name instead of the resolved resource ID. Retain the actual IResourceValue on ObjectDeploymentSymbol and ArrayDeploymentSymbol via a new IDeploymentSymbol.GetResource(index), and have TryGetResource resolve existing resources directly from the retained value instead of the resourceId lookup table. Also fixes a related bug in Functions.Concat that prevented mock array properties (Mock.MockUnknownObject) from being consumed like Map/Union already do via ExpressionHelpers.TryArray, and a double-mutation bug introduced while fixing that, where Concat called TryArray twice on the same argument, causing the underlying JToken to be detached by the first Replace() and then fail on the second with "The parent is missing". Fixes #3920 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/changelog.md | 2 ++ .../Arm/Expressions/Functions.cs | 14 ++++----- .../PSRule.Rules.Azure.Tests.csproj | 6 ++++ .../TemplateVisitorTests.cs | 27 +++++++++++++++++ .../Tests.Bicep.44.json | 29 +++++++++++++++++++ .../Tests.Bicep.45.json | 11 +++++++ 6 files changed, 81 insertions(+), 8 deletions(-) create mode 100644 tests/PSRule.Rules.Azure.Tests/Tests.Bicep.44.json create mode 100644 tests/PSRule.Rules.Azure.Tests/Tests.Bicep.45.json diff --git a/docs/changelog.md b/docs/changelog.md index 183a98f9258..7a5c63810af 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -46,6 +46,8 @@ What's changed since pre-release v1.48.0-B0228: [#3907](https://github.com/Azure/PSRule.Rules.Azure/issues/3907) - Fixed `tryGet` throwing instead of returning `null` for a property lookup against an array by @oWretch. [#3907](https://github.com/Azure/PSRule.Rules.Azure/issues/3907) + - Fixed cross-scope `existing` resource reference via symbolic name resolving to a malformed mock, breaking `concat()`/`map()` during pre-flight expansion. + [#3920](https://github.com/Azure/PSRule.Rules.Azure/issues/3920) - Engineering: - Bump YamlDotNet to 11.2.5. diff --git a/src/PSRule.Rules.Azure/Arm/Expressions/Functions.cs b/src/PSRule.Rules.Azure/Arm/Expressions/Functions.cs index 9b35655ab83..ada6d510afc 100644 --- a/src/PSRule.Rules.Azure/Arm/Expressions/Functions.cs +++ b/src/PSRule.Rules.Azure/Arm/Expressions/Functions.cs @@ -289,21 +289,19 @@ internal static object Concat(ITemplateContext context, object[] args) return result.ToString(); } // Array - else if (args[0] is Array || args[0] is JArray) + else if (ExpressionHelpers.TryArray(args[0], out var firstArray)) { var result = new List(); - for (var i = 0; i < args.Length; i++) + for (var j = 0; j < firstArray.Length; j++) + result.Add(firstArray.GetValue(j)); + + for (var i = 1; i < args.Length; i++) { - if (args[i] is Array array) + if (ExpressionHelpers.TryArray(args[i], out var array)) { for (var j = 0; j < array.Length; j++) result.Add(array.GetValue(j)); } - else if (args[i] is JArray jArray) - { - for (var j = 0; j < jArray.Count; j++) - result.Add(jArray[j]); - } } return result.ToArray(); } diff --git a/tests/PSRule.Rules.Azure.Tests/PSRule.Rules.Azure.Tests.csproj b/tests/PSRule.Rules.Azure.Tests/PSRule.Rules.Azure.Tests.csproj index d540d207a37..7156ad0c8ee 100644 --- a/tests/PSRule.Rules.Azure.Tests/PSRule.Rules.Azure.Tests.csproj +++ b/tests/PSRule.Rules.Azure.Tests/PSRule.Rules.Azure.Tests.csproj @@ -320,6 +320,12 @@ PreserveNewest + + PreserveNewest + + + PreserveNewest + PreserveNewest diff --git a/tests/PSRule.Rules.Azure.Tests/TemplateVisitorTests.cs b/tests/PSRule.Rules.Azure.Tests/TemplateVisitorTests.cs index 04678391fc6..26d2b940cd7 100644 --- a/tests/PSRule.Rules.Azure.Tests/TemplateVisitorTests.cs +++ b/tests/PSRule.Rules.Azure.Tests/TemplateVisitorTests.cs @@ -1417,4 +1417,31 @@ public void ProcessTemplate_WhenNullableIndexFromEnd_ShouldReturnExpectedValue() var secondLastPart = secondLastPartOutput["value"].Value(); Assert.Equal("eastus", secondLastPart); } + + /// + /// Test case for https://github.com/Azure/PSRule.Rules.Azure/issues/3920 + /// + [Fact] + public void ProcessTemplate_WhenExistingResourceReferencedBySymbolicName_ShouldReturnMock() + { + _ = ProcessTemplate(GetSourcePath("Tests.Bicep.44.json"), null, out var templateContext); + + Assert.True(templateContext.RootDeployment.TryOutput("addressPrefixes", out JObject addressPrefixesOutput)); + Assert.Equal(JTokenType.Array, addressPrefixesOutput["value"].Type); + + Assert.True(templateContext.RootDeployment.TryOutput("combinedAddressPrefixes", out JObject combinedAddressPrefixesOutput)); + Assert.Equal(JTokenType.Array, combinedAddressPrefixesOutput["value"].Type); + + Assert.True(templateContext.RootDeployment.TryOutput("mappedAddressPrefixes", out JObject mappedAddressPrefixesOutput)); + Assert.Equal(JTokenType.Array, mappedAddressPrefixesOutput["value"].Type); + } + + [Fact] + public void ProcessTemplate_WhenExistingResourceReferencedByResourceId_ShouldReturnMock() + { + _ = ProcessTemplate(GetSourcePath("Tests.Bicep.45.json"), null, out var templateContext); + + Assert.True(templateContext.RootDeployment.TryOutput("combinedAddressPrefixes", out JObject combinedAddressPrefixesOutput)); + Assert.Equal(JTokenType.Array, combinedAddressPrefixesOutput["value"].Type); + } } diff --git a/tests/PSRule.Rules.Azure.Tests/Tests.Bicep.44.json b/tests/PSRule.Rules.Azure.Tests/Tests.Bicep.44.json new file mode 100644 index 00000000000..636be037fe5 --- /dev/null +++ b/tests/PSRule.Rules.Azure.Tests/Tests.Bicep.44.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "languageVersion": "2.0", + "contentVersion": "1.0.0.0", + "resources": { + "vnet": { + "existing": true, + "type": "Microsoft.Network/virtualNetworks", + "apiVersion": "2023-11-01", + "subscriptionId": "[subscription().subscriptionId]", + "resourceGroup": "other-rg", + "name": "vnet1" + } + }, + "outputs": { + "addressPrefixes": { + "type": "array", + "value": "[reference('vnet').addressSpace.addressPrefixes]" + }, + "combinedAddressPrefixes": { + "type": "array", + "value": "[concat(reference('vnet').addressSpace.addressPrefixes, createArray())]" + }, + "mappedAddressPrefixes": { + "type": "array", + "value": "[map(reference('vnet').addressSpace.addressPrefixes, lambda('prefix', lambdaVariables('prefix')))]" + } + } +} diff --git a/tests/PSRule.Rules.Azure.Tests/Tests.Bicep.45.json b/tests/PSRule.Rules.Azure.Tests/Tests.Bicep.45.json new file mode 100644 index 00000000000..9154ef5f629 --- /dev/null +++ b/tests/PSRule.Rules.Azure.Tests/Tests.Bicep.45.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "resources": [], + "outputs": { + "combinedAddressPrefixes": { + "type": "array", + "value": "[concat(reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, 'other-rg'), 'Microsoft.Network/virtualNetworks', 'vnet1'), '2023-11-01').addressSpace.addressPrefixes, createArray())]" + } + } +}