From 78ed7c314a287b89e39ac9f8be18fca1cf07fa7d Mon Sep 17 00:00:00 2001 From: rosebyte Date: Tue, 28 Jul 2026 18:36:44 +0200 Subject: [PATCH 1/3] consider empty string as a legit value in ChainedConfigurationProvider --- .../src/ChainedConfigurationProvider.cs | 4 +- .../ChainedConfigurationProviderTests.cs | 249 ++++++++++++++++++ 2 files changed, 251 insertions(+), 2 deletions(-) diff --git a/src/libraries/Microsoft.Extensions.Configuration/src/ChainedConfigurationProvider.cs b/src/libraries/Microsoft.Extensions.Configuration/src/ChainedConfigurationProvider.cs index b82810acc71e3d..ab1b815a17e573 100644 --- a/src/libraries/Microsoft.Extensions.Configuration/src/ChainedConfigurationProvider.cs +++ b/src/libraries/Microsoft.Extensions.Configuration/src/ChainedConfigurationProvider.cs @@ -38,11 +38,11 @@ public ChainedConfigurationProvider(ChainedConfigurationSource source) /// /// The key. /// When this method returns, contains the value. - /// if a value for the specified key was found, otherwise . + /// if the chained configuration has a value for the specified key, otherwise . public bool TryGet(string key, out string? value) { value = _config[key]; - return !string.IsNullOrEmpty(value); + return value is not null; } /// diff --git a/src/libraries/Microsoft.Extensions.Configuration/tests/ChainedConfigurationProviderTests.cs b/src/libraries/Microsoft.Extensions.Configuration/tests/ChainedConfigurationProviderTests.cs index 7fde62c4115824..98c1e6f15104a9 100644 --- a/src/libraries/Microsoft.Extensions.Configuration/tests/ChainedConfigurationProviderTests.cs +++ b/src/libraries/Microsoft.Extensions.Configuration/tests/ChainedConfigurationProviderTests.cs @@ -1,10 +1,12 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System; using System.Collections.Generic; using System.Globalization; using System.Linq; using Microsoft.Extensions.Configuration.Memory; +using Microsoft.Extensions.Primitives; using Xunit; namespace Microsoft.Extensions.Configuration.Test @@ -153,6 +155,253 @@ public void ChainedConfiguration_ReloadingOuterConfigurationRoot_RaisesSingleOut Assert.Equal(0, innerNotifications); } + [Theory] + [InlineData("")] + [InlineData("inner-value")] + public void ChainedConfiguration_OverConfigurationRoot_TryGetFindsKeyWithNonNullValue(string value) + { + IConfigurationProvider provider = BuildChainedProvider(new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary { { "Key", value } }) + .Build()); + + Assert.True(provider.TryGet("Key", out string actual)); + Assert.Equal(value, actual); + } + + [Theory] + [InlineData("")] + [InlineData("inner-value")] + public void ChainedConfiguration_OverConfigurationSection_TryGetFindsKeyWithNonNullValue(string value) + { + IConfigurationProvider provider = BuildChainedProvider(new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary { { "Section:Key", value } }) + .Build() + .GetSection("Section")); + + Assert.True(provider.TryGet("Key", out string actual)); + Assert.Equal(value, actual); + } + + [Theory] + [InlineData("")] + [InlineData("inner-value")] + public void ChainedConfiguration_OverConfigurationManager_TryGetFindsKeyWithNonNullValue(string value) + { + using var inner = new ConfigurationManager(); + inner.AddInMemoryCollection(new Dictionary { { "Key", value } }); + + IConfigurationProvider provider = BuildChainedProvider(inner); + + Assert.True(provider.TryGet("Key", out string actual)); + Assert.Equal(value, actual); + } + + [Theory] + [InlineData("")] + [InlineData("inner-value")] + public void ChainedConfiguration_ShadowsPrecedingProvider(string value) + { + var inner = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary { { "Key", value } }) + .Build(); + + var outer = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary { { "Key", "earlier-value" } }) + .AddConfiguration(inner) + .Build(); + + Assert.Equal(value, outer["Key"]); + } + + [Theory] + [InlineData("")] + [InlineData("inner-value")] + public void ChainedConfiguration_OverConfigurationSection_ShadowsPrecedingProvider(string value) + { + var inner = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary { { "Section:Key", value } }) + .Build(); + + var outer = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary { { "Key", "earlier-value" } }) + .AddConfiguration(inner.GetSection("Section")) + .Build(); + + Assert.Equal(value, outer["Key"]); + } + + [Theory] + [InlineData("")] + [InlineData("inner-value")] + public void ChainedConfiguration_BindsSameValueAsAnEquivalentDirectlyAddedSource(string value) + { + var direct = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary { { "Key", "earlier-value" } }) + .AddInMemoryCollection(new Dictionary { { "Key", value } }) + .Build(); + + var chained = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary { { "Key", "earlier-value" } }) + .AddConfiguration(new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary { { "Key", value } }) + .Build()) + .Build(); + + var directOptions = new OptionsWithPresetValue(); + var chainedOptions = new OptionsWithPresetValue(); + +#pragma warning disable IL2026, IL3050 // https://github.com/dotnet/runtime/issues/126862 + direct.Bind(directOptions); + chained.Bind(chainedOptions); +#pragma warning restore IL2026, IL3050 + + Assert.Equal(value, directOptions.Key); + Assert.Equal(value, chainedOptions.Key); + } + + [Theory] + [InlineData("")] + [InlineData("inner-value")] + public void ChainedConfiguration_TryGetAgreesWithGetChildKeys(string value) + { + IConfigurationProvider provider = BuildChainedProvider(new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary { { "Key", value } }) + .Build()); + + // GetChildKeys enumerates the wrapped configuration, which lists a key whatever its value. TryGet has to + // agree, otherwise the provider announces a key that it then refuses to return. + Assert.Contains("Key", provider.GetChildKeys(Array.Empty(), parentPath: null)); + Assert.True(provider.TryGet("Key", out _)); + } + + public static TheoryData> ChainedConfigurationKinds => new() + { + root => root, + root => root.GetSection("Section"), + root => new PlainConfiguration(root), + }; + + [Theory] + [MemberData(nameof(ChainedConfigurationKinds))] + public void ChainedConfiguration_NullValueIsNotContributed(Func selectConfiguration) + { + var inner = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary { { "Key", null }, { "Section:Key", null } }) + .Build(); + + var outer = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary { { "Key", "earlier-value" } }) + .AddConfiguration(selectConfiguration(inner)) + .Build(); + + // A chained configuration is a merged unit, and a unit reports the absence of a value as null. There is + // nothing to contribute, so the preceding provider still wins. + Assert.Equal("earlier-value", outer["Key"]); + } + + [Theory] + [MemberData(nameof(ChainedConfigurationKinds))] + public void ChainedConfiguration_TryGetReturnsFalseForMissingKey(Func selectConfiguration) + { + var inner = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary { { "Section:Key", "inner-value" } }) + .Build(); + + IConfigurationProvider provider = BuildChainedProvider(selectConfiguration(inner)); + + Assert.False(provider.TryGet("MissingKey", out string value)); + Assert.Null(value); + } + + [Theory] + [InlineData("")] + [InlineData("inner-value")] + public void ChainedConfiguration_SectionWithNonNullValueExists(string value) + { + var outer = new ConfigurationBuilder() + .AddConfiguration(new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary { { "Key", value } }) + .Build()) + .Build(); + + Assert.True(outer.GetSection("Key").Exists()); + Assert.Equal(value, outer.GetRequiredSection("Key").Value); + } + + [Fact] + public void ChainedConfiguration_EmptyValueShadowingATypedValue_FailsToBindLikeADirectlyAddedSource() + { + static IConfigurationBuilder AddEarlierProvider(IConfigurationBuilder builder) + => builder.AddInMemoryCollection(new Dictionary { { "Port", "9000" } }); + + var direct = AddEarlierProvider(new ConfigurationBuilder()) + .AddInMemoryCollection(new Dictionary { { "Port", "" } }) + .Build(); + + var chained = AddEarlierProvider(new ConfigurationBuilder()) + .AddConfiguration(new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary { { "Port", "" } }) + .Build()) + .Build(); + + // An empty value shadows the earlier provider, so binding it to a non-string type fails. This matches + // what an equivalent directly added source has always done. +#pragma warning disable IL2026, IL3050 // https://github.com/dotnet/runtime/issues/126862 + Assert.Throws(() => direct.Bind(new TypedOptions())); + Assert.Throws(() => chained.Bind(new TypedOptions())); +#pragma warning restore IL2026, IL3050 + } + + [Fact] + public void ChainedConfiguration_MissingKeyDoesNotShadowPrecedingProvider() + { + var outer = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary { { "Key", "earlier-value" } }) + .AddConfiguration(new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary { { "OtherKey", "inner-value" } }) + .Build()) + .Build(); + + Assert.Equal("earlier-value", outer["Key"]); + } + + private static IConfigurationProvider BuildChainedProvider(IConfiguration configuration) + => new ChainedConfigurationSource + { + Configuration = configuration, + ShouldDisposeConfiguration = false, + } + .Build(new ConfigurationBuilder()); + + private class OptionsWithPresetValue + { + public string Key { get; set; } = "preset-value"; + } + + private class TypedOptions + { + public int Port { get; set; } + } + + private class PlainConfiguration : IConfiguration + { + private readonly IConfiguration _inner; + + public PlainConfiguration(IConfiguration inner) => _inner = inner; + + public string this[string key] + { + get => _inner[key]; + set => _inner[key] = value; + } + + public IEnumerable GetChildren() => _inner.GetChildren(); + + public IChangeToken GetReloadToken() => _inner.GetReloadToken(); + + public IConfigurationSection GetSection(string key) => _inner.GetSection(key); + } + private class TestConfigurationProvider : ConfigurationProvider { public TestConfigurationProvider(string key, string value) From 09b92cffd91bedac5016d3c8c330342b5847930e Mon Sep 17 00:00:00 2001 From: Jaroslav Ruzicka <14963300+rosebyte@users.noreply.github.com> Date: Wed, 29 Jul 2026 23:33:03 +0200 Subject: [PATCH 2/3] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../tests/ChainedConfigurationProviderTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libraries/Microsoft.Extensions.Configuration/tests/ChainedConfigurationProviderTests.cs b/src/libraries/Microsoft.Extensions.Configuration/tests/ChainedConfigurationProviderTests.cs index 98c1e6f15104a9..8dc057fffd5de8 100644 --- a/src/libraries/Microsoft.Extensions.Configuration/tests/ChainedConfigurationProviderTests.cs +++ b/src/libraries/Microsoft.Extensions.Configuration/tests/ChainedConfigurationProviderTests.cs @@ -286,7 +286,7 @@ public void ChainedConfiguration_TryGetAgreesWithGetChildKeys(string value) public void ChainedConfiguration_NullValueIsNotContributed(Func selectConfiguration) { var inner = new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary { { "Key", null }, { "Section:Key", null } }) + .AddInMemoryCollection(new Dictionary { { "Key", null }, { "Section:Key", null } }) .Build(); var outer = new ConfigurationBuilder() From 66588b59c2358b0b0b64c346935bd5b504a75e72 Mon Sep 17 00:00:00 2001 From: rosebyte Date: Thu, 30 Jul 2026 15:38:50 +0200 Subject: [PATCH 3/3] address nits --- .../src/ChainedConfigurationProvider.cs | 2 +- .../tests/ChainedConfigurationProviderTests.cs | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/libraries/Microsoft.Extensions.Configuration/src/ChainedConfigurationProvider.cs b/src/libraries/Microsoft.Extensions.Configuration/src/ChainedConfigurationProvider.cs index ab1b815a17e573..08cec0453bd211 100644 --- a/src/libraries/Microsoft.Extensions.Configuration/src/ChainedConfigurationProvider.cs +++ b/src/libraries/Microsoft.Extensions.Configuration/src/ChainedConfigurationProvider.cs @@ -38,7 +38,7 @@ public ChainedConfigurationProvider(ChainedConfigurationSource source) /// /// The key. /// When this method returns, contains the value. - /// if the chained configuration has a value for the specified key, otherwise . + /// if the chained configuration has a non- value for the specified key, otherwise . public bool TryGet(string key, out string? value) { value = _config[key]; diff --git a/src/libraries/Microsoft.Extensions.Configuration/tests/ChainedConfigurationProviderTests.cs b/src/libraries/Microsoft.Extensions.Configuration/tests/ChainedConfigurationProviderTests.cs index 8dc057fffd5de8..506d912ce608e0 100644 --- a/src/libraries/Microsoft.Extensions.Configuration/tests/ChainedConfigurationProviderTests.cs +++ b/src/libraries/Microsoft.Extensions.Configuration/tests/ChainedConfigurationProviderTests.cs @@ -164,7 +164,7 @@ public void ChainedConfiguration_OverConfigurationRoot_TryGetFindsKeyWithNonNull .AddInMemoryCollection(new Dictionary { { "Key", value } }) .Build()); - Assert.True(provider.TryGet("Key", out string actual)); + Assert.True(provider.TryGet("Key", out string? actual)); Assert.Equal(value, actual); } @@ -178,7 +178,7 @@ public void ChainedConfiguration_OverConfigurationSection_TryGetFindsKeyWithNonN .Build() .GetSection("Section")); - Assert.True(provider.TryGet("Key", out string actual)); + Assert.True(provider.TryGet("Key", out string? actual)); Assert.Equal(value, actual); } @@ -192,7 +192,7 @@ public void ChainedConfiguration_OverConfigurationManager_TryGetFindsKeyWithNonN IConfigurationProvider provider = BuildChainedProvider(inner); - Assert.True(provider.TryGet("Key", out string actual)); + Assert.True(provider.TryGet("Key", out string? actual)); Assert.Equal(value, actual); } @@ -309,7 +309,7 @@ public void ChainedConfiguration_TryGetReturnsFalseForMissingKey(Func _inner = inner; - public string this[string key] + public string? this[string key] { get => _inner[key]; set => _inner[key] = value;