From 75adb939dfc43a9947cbeede1974cc961abb68f6 Mon Sep 17 00:00:00 2001 From: rosebyte Date: Wed, 22 Apr 2026 11:27:30 +0200 Subject: [PATCH 01/10] configuration references draft --- .../ref/Microsoft.Extensions.Configuration.cs | 14 +- .../src/ConfigurationBuilder.cs | 13 +- .../src/ConfigurationManager.cs | 100 ++- .../src/ConfigurationRoot.cs | 22 +- .../InternalConfigurationRootExtensions.cs | 24 +- .../src/ReferenceCountedProvidersManager.cs | 16 + .../src/ReferenceMode.cs | 43 + .../src/ReferenceParser.cs | 281 +++++++ ...esolutionConfigurationBuilderExtensions.cs | 190 +++++ .../src/ReferenceResolutionEngine.cs | 767 ++++++++++++++++++ .../src/Resources/Strings.resx | 32 +- .../tests/ConfigurationManagerTest.cs | 169 ++++ .../tests/ConfigurationTest.cs | 708 ++++++++++++++++ .../FunctionalTests/ConfigurationTests.cs | 74 ++ ...ions.Configuration.Functional.Tests.csproj | 1 + .../tests/ReferenceResolutionTestShims.cs | 49 ++ 16 files changed, 2490 insertions(+), 13 deletions(-) create mode 100644 src/libraries/Microsoft.Extensions.Configuration/src/ReferenceMode.cs create mode 100644 src/libraries/Microsoft.Extensions.Configuration/src/ReferenceParser.cs create mode 100644 src/libraries/Microsoft.Extensions.Configuration/src/ReferenceResolutionConfigurationBuilderExtensions.cs create mode 100644 src/libraries/Microsoft.Extensions.Configuration/src/ReferenceResolutionEngine.cs create mode 100644 src/libraries/Microsoft.Extensions.Configuration/tests/ReferenceResolutionTestShims.cs diff --git a/src/libraries/Microsoft.Extensions.Configuration/ref/Microsoft.Extensions.Configuration.cs b/src/libraries/Microsoft.Extensions.Configuration/ref/Microsoft.Extensions.Configuration.cs index 31de97acabf21a..ffcbf3c2995e81 100644 --- a/src/libraries/Microsoft.Extensions.Configuration/ref/Microsoft.Extensions.Configuration.cs +++ b/src/libraries/Microsoft.Extensions.Configuration/ref/Microsoft.Extensions.Configuration.cs @@ -97,16 +97,28 @@ public ConfigurationSection(Microsoft.Extensions.Configuration.IConfigurationRoo public string Key { get { throw null; } } public string Path { get { throw null; } } public string? Value { get { throw null; } set { } } - public bool TryGetValue(string? key, out string? value) { throw null; } public System.Collections.Generic.IEnumerable GetChildren() { throw null; } public Microsoft.Extensions.Primitives.IChangeToken GetReloadToken() { throw null; } public Microsoft.Extensions.Configuration.IConfigurationSection GetSection(string key) { throw null; } + public bool TryGetValue(string? key, out string? value) { throw null; } } public static partial class MemoryConfigurationBuilderExtensions { public static Microsoft.Extensions.Configuration.IConfigurationBuilder AddInMemoryCollection(this Microsoft.Extensions.Configuration.IConfigurationBuilder configurationBuilder) { throw null; } public static Microsoft.Extensions.Configuration.IConfigurationBuilder AddInMemoryCollection(this Microsoft.Extensions.Configuration.IConfigurationBuilder configurationBuilder, System.Collections.Generic.IEnumerable>? initialData) { throw null; } } + public enum ReferenceMode + { + Ignore = 0, + Read = 1, + Scan = 2, + } + public static partial class ReferenceResolutionConfigurationBuilderExtensions + { + public static Microsoft.Extensions.Configuration.IConfigurationBuilder SetReferenceMode(this Microsoft.Extensions.Configuration.IConfigurationBuilder configurationBuilder, Microsoft.Extensions.Configuration.IConfigurationSource source, Microsoft.Extensions.Configuration.ReferenceMode mode) { throw null; } + public static Microsoft.Extensions.Configuration.IConfigurationBuilder SetReferenceMode(this Microsoft.Extensions.Configuration.IConfigurationBuilder configurationBuilder, Microsoft.Extensions.Configuration.ReferenceMode mode) { throw null; } + public static Microsoft.Extensions.Configuration.IConfigurationBuilder SetReferenceMode(this Microsoft.Extensions.Configuration.IConfigurationBuilder configurationBuilder, System.Collections.Generic.IEnumerable sources, Microsoft.Extensions.Configuration.ReferenceMode mode) { throw null; } + } public abstract partial class StreamConfigurationProvider : Microsoft.Extensions.Configuration.ConfigurationProvider { public StreamConfigurationProvider(Microsoft.Extensions.Configuration.StreamConfigurationSource source) { } diff --git a/src/libraries/Microsoft.Extensions.Configuration/src/ConfigurationBuilder.cs b/src/libraries/Microsoft.Extensions.Configuration/src/ConfigurationBuilder.cs index 75b930f60f16eb..ccc70d2c83a71b 100644 --- a/src/libraries/Microsoft.Extensions.Configuration/src/ConfigurationBuilder.cs +++ b/src/libraries/Microsoft.Extensions.Configuration/src/ConfigurationBuilder.cs @@ -47,10 +47,17 @@ public IConfigurationRoot Build() var providers = new List(); foreach (IConfigurationSource source in _sources) { - IConfigurationProvider provider = source.Build(this); - providers.Add(provider); + providers.Add(source.Build(this)); } - return new ConfigurationRoot(providers); + + ReferenceResolutionEngine? engine = null; + if (ReferenceResolutionConfigurationBuilderExtensions.HasAnyScanSource(Properties)) + { + Dictionary? providerModes = ReferenceResolutionConfigurationBuilderExtensions + .ResolveProviderModes(Properties, _sources, providers); + engine = new ReferenceResolutionEngine(providers, providerModes); + } + return new ConfigurationRoot(providers, engine); } } } diff --git a/src/libraries/Microsoft.Extensions.Configuration/src/ConfigurationManager.cs b/src/libraries/Microsoft.Extensions.Configuration/src/ConfigurationManager.cs index b31c4bb0c6392e..7c312a1770c40f 100644 --- a/src/libraries/Microsoft.Extensions.Configuration/src/ConfigurationManager.cs +++ b/src/libraries/Microsoft.Extensions.Configuration/src/ConfigurationManager.cs @@ -37,6 +37,12 @@ public sealed class ConfigurationManager : IConfigurationManager, IConfiguration private readonly List _changeTokenRegistrations = new(); private ConfigurationReloadToken _changeToken = new(); + // Non-null when the builder opted into reference resolution via EnableReferenceResolution. + // Rebuilt on every source mutation (AddSource/ReloadSources) so it always reflects the + // current provider set. Reads are unsynchronized; in-flight reads that observe a stale + // engine still see a consistent (old) provider snapshot held by that engine. + private ReferenceResolutionEngine? _engine; + /// /// Creates an empty mutable configuration object that is both an and an . /// @@ -54,6 +60,12 @@ public string? this[string key] { get { + ReferenceResolutionEngine? engine = _engine; + if (engine is not null) + { + return engine.TryGet(key, out string? resolved) ? resolved : null; + } + using ReferenceCountedProviders reference = _providerManager.GetReference(); return ConfigurationRoot.GetConfiguration(reference.Providers, key); } @@ -64,6 +76,8 @@ public string? this[string key] } } + internal ReferenceResolutionEngine? Engine => _engine; + /// public IConfigurationSection GetSection(string key) => new ConfigurationSection(this, key); @@ -84,6 +98,7 @@ public string? this[string key] public void Dispose() { DisposeRegistrations(); + Interlocked.Exchange(ref _engine, null)?.Dispose(); _providerManager.Dispose(); } @@ -109,6 +124,7 @@ void IConfigurationRoot.Reload() } } + _engine?.Invalidate(); RaiseChanged(); } @@ -129,6 +145,7 @@ private void AddSource(IConfigurationSource source) _changeTokenRegistrations.Add(ChangeToken.OnChange(provider.GetReloadToken, RaiseChanged)); _providerManager.AddProvider(provider); + SwapEngine(); RaiseChanged(); } @@ -153,9 +170,30 @@ private void ReloadSources() } _providerManager.ReplaceProviders(newProvidersList); + SwapEngine(); RaiseChanged(); } + // Rebuild the engine against the current provider set when resolution is enabled. + // The old engine's providers are the previous snapshot, and any in-flight read that + // already captured the old engine will complete against that snapshot; a subsequent + // read will pick up the new engine. The old engine is disposed to drop its reload- + // token subscription against the old providers. + private void SwapEngine() + { + ReferenceResolutionEngine? newEngine = null; + if (ReferenceResolutionConfigurationBuilderExtensions.HasAnyScanSource(_properties.Raw)) + { + IReadOnlyList providers = _providerManager.GetProvidersSnapshot(); + Dictionary? providerModes = ReferenceResolutionConfigurationBuilderExtensions + .ResolveProviderModes(_properties.Raw, _sources, providers); + newEngine = new ReferenceResolutionEngine(providers, providerModes); + } + + ReferenceResolutionEngine? previous = Interlocked.Exchange(ref _engine, newEngine); + previous?.Dispose(); + } + private void DisposeRegistrations() { // dispose change token registrations @@ -276,7 +314,14 @@ public object this[string key] set { _properties[key] = value; - _config.ReloadSources(); + if (IsReferenceResolutionProperty(key)) + { + _config.SwapEngine(); + } + else + { + _config.ReloadSources(); + } } } @@ -291,13 +336,27 @@ public object this[string key] public void Add(string key, object value) { _properties.Add(key, value); - _config.ReloadSources(); + if (IsReferenceResolutionProperty(key)) + { + _config.SwapEngine(); + } + else + { + _config.ReloadSources(); + } } public void Add(KeyValuePair item) { ((IDictionary)_properties).Add(item); - _config.ReloadSources(); + if (IsReferenceResolutionProperty(item.Key)) + { + _config.SwapEngine(); + } + else + { + _config.ReloadSources(); + } } public void Clear() @@ -329,17 +388,43 @@ public IEnumerator> GetEnumerator() public bool Remove(string key) { var wasRemoved = _properties.Remove(key); - _config.ReloadSources(); + if (IsReferenceResolutionProperty(key)) + { + _config.SwapEngine(); + } + else + { + _config.ReloadSources(); + } + return wasRemoved; } public bool Remove(KeyValuePair item) { var wasRemoved = ((IDictionary)_properties).Remove(item); - _config.ReloadSources(); + if (IsReferenceResolutionProperty(item.Key)) + { + _config.SwapEngine(); + } + else + { + _config.ReloadSources(); + } + return wasRemoved; } + // Reference-resolution property writes only affect how the root interprets the existing + // provider set; they never change which providers exist or what they hold. Handling them + // through SwapEngine avoids the O(n) provider rebuild/Load cost that a general property + // write triggers via ReloadSources, so enabling or reconfiguring resolution mid-setup + // does not penalize callers using slow-to-load sources (e.g. Azure App Configuration). + private static bool IsReferenceResolutionProperty(string key) + { + return key == ReferenceResolutionConfigurationBuilderExtensions.SourceModesPropertyName; + } + public bool TryGetValue(string key, [NotNullWhen(true)] out object? value) { return _properties.TryGetValue(key, out value); @@ -349,6 +434,11 @@ IEnumerator IEnumerable.GetEnumerator() { return _properties.GetEnumerator(); } + + // Direct accessor used by the ConfigurationManager build/reload loop to mutate + // well-known properties (e.g. PreviouslyBuiltProviders) without triggering another + // ReloadSources via the IDictionary interface this class exposes publicly. + internal IDictionary Raw => _properties; } } } diff --git a/src/libraries/Microsoft.Extensions.Configuration/src/ConfigurationRoot.cs b/src/libraries/Microsoft.Extensions.Configuration/src/ConfigurationRoot.cs index bd53b747a43822..d260a11c643965 100644 --- a/src/libraries/Microsoft.Extensions.Configuration/src/ConfigurationRoot.cs +++ b/src/libraries/Microsoft.Extensions.Configuration/src/ConfigurationRoot.cs @@ -17,6 +17,7 @@ namespace Microsoft.Extensions.Configuration public class ConfigurationRoot : IConfigurationRoot, IDisposable { private readonly IList _providers; + private readonly ReferenceResolutionEngine? _engine; private readonly List _changeTokenRegistrations; private ConfigurationReloadToken _changeToken = new ConfigurationReloadToken(); @@ -25,10 +26,16 @@ public class ConfigurationRoot : IConfigurationRoot, IDisposable /// /// The s for this configuration. public ConfigurationRoot(IList providers) + : this(providers, engine: null) + { + } + + internal ConfigurationRoot(IList providers, ReferenceResolutionEngine? engine) { ArgumentNullException.ThrowIfNull(providers); _providers = providers; + _engine = engine; _changeTokenRegistrations = new List(providers.Count); foreach (IConfigurationProvider p in providers) { @@ -42,6 +49,8 @@ public ConfigurationRoot(IList providers) /// public IEnumerable Providers => _providers; + internal ReferenceResolutionEngine? Engine => _engine; + /// /// Gets or sets the value corresponding to a configuration key. /// @@ -49,7 +58,15 @@ public ConfigurationRoot(IList providers) /// The configuration value. public string? this[string key] { - get => GetConfiguration(_providers, key); + get + { + if (_engine is not null) + { + return _engine.TryGet(key, out string? resolved) ? resolved : null; + } + + return GetConfiguration(_providers, key); + } set => SetConfiguration(_providers, key, value); } @@ -86,6 +103,7 @@ public void Reload() { provider.Load(); } + _engine?.Invalidate(); RaiseChanged(); } @@ -109,6 +127,8 @@ public void Dispose() { (provider as IDisposable)?.Dispose(); } + + _engine?.Dispose(); } internal static string? GetConfiguration(IList providers, string key) diff --git a/src/libraries/Microsoft.Extensions.Configuration/src/InternalConfigurationRootExtensions.cs b/src/libraries/Microsoft.Extensions.Configuration/src/InternalConfigurationRootExtensions.cs index a4a57d45d59930..0666b0b8947409 100644 --- a/src/libraries/Microsoft.Extensions.Configuration/src/InternalConfigurationRootExtensions.cs +++ b/src/libraries/Microsoft.Extensions.Configuration/src/InternalConfigurationRootExtensions.cs @@ -23,9 +23,17 @@ internal static IEnumerable GetChildrenImplementation(thi using ReferenceCountedProviders? reference = (root as ConfigurationManager)?.GetProvidersReference(); IEnumerable providers = reference?.Providers ?? root.Providers; - IEnumerable children = providers + IEnumerable keys = providers .Aggregate(Enumerable.Empty(), - (seed, source) => source.GetChildKeys(seed, path)) + (seed, source) => source.GetChildKeys(seed, path)); + + ReferenceResolutionEngine? engine = (root as ConfigurationRoot)?.Engine ?? (root as ConfigurationManager)?.Engine; + if (engine is not null) + { + keys = engine.GetChildKeys(keys, path); + } + + IEnumerable children = keys .Distinct(StringComparer.OrdinalIgnoreCase) .Select(key => root.GetSection(path == null ? key : path + ConfigurationPath.KeyDelimiter + key)); @@ -42,6 +50,18 @@ internal static IEnumerable GetChildrenImplementation(thi internal static bool TryGetConfiguration(this IConfigurationRoot root, string key, out string? value) { + ReferenceResolutionEngine? engine = (root as ConfigurationRoot)?.Engine ?? (root as ConfigurationManager)?.Engine; + if (engine is not null) + { + if (engine.TryGet(key, out value)) + { + return true; + } + + value = null; + return false; + } + // common cases Providers is IList in ConfigurationRoot IList providers = root.Providers is IList list ? list diff --git a/src/libraries/Microsoft.Extensions.Configuration/src/ReferenceCountedProvidersManager.cs b/src/libraries/Microsoft.Extensions.Configuration/src/ReferenceCountedProvidersManager.cs index 210e41f665a47a..70f3fa8ed2702e 100644 --- a/src/libraries/Microsoft.Extensions.Configuration/src/ReferenceCountedProvidersManager.cs +++ b/src/libraries/Microsoft.Extensions.Configuration/src/ReferenceCountedProvidersManager.cs @@ -75,6 +75,22 @@ public void AddProvider(IConfigurationProvider provider) } } + // Returns an immutable snapshot of the currently-built providers. Used by the builder's + // source.Build(...) loop so a ReferenceResolutionConfigurationSource can observe providers + // registered before it. + public IReadOnlyList GetProvidersSnapshot() + { + lock (_replaceProvidersLock) + { + if (_disposed) + { + throw new ObjectDisposedException(nameof(ConfigurationManager)); + } + + return _refCountedProviders.Providers.ToArray(); + } + } + public void Dispose() { ReferenceCountedProviders oldRefCountedProviders = _refCountedProviders; diff --git a/src/libraries/Microsoft.Extensions.Configuration/src/ReferenceMode.cs b/src/libraries/Microsoft.Extensions.Configuration/src/ReferenceMode.cs new file mode 100644 index 00000000000000..dfff359b026a69 --- /dev/null +++ b/src/libraries/Microsoft.Extensions.Configuration/src/ReferenceMode.cs @@ -0,0 +1,43 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.Extensions.Configuration +{ + /// + /// Controls how an participates in ${...} + /// reference resolution performed by the built from the + /// containing . + /// + /// + /// Values are totally ordered by participation: < + /// < . Sources default to unless configured via + /// + /// or one of its overloads. At least one source must be for the + /// reference-resolution engine to activate on the built root. + /// + public enum ReferenceMode + { + /// + /// The source is invisible to the reference-resolution engine: no ${...} + /// reference or section alias in another source can reach its values. Direct reads + /// via the normal API still return its values. + /// + Ignore = 0, + + /// + /// The source is a valid substitution target for references in other + /// sources. The source's own values are returned verbatim — + /// ${...} sequences are not interpreted. This is the default mode for + /// sources that have not been configured explicitly. + /// + Read = 1, + + /// + /// The source's values are scanned for ${...} reference tokens and section + /// aliases, and the source is exposed as a substitution target for other + /// sources. Marking at least one source with this value + /// activates the reference-resolution engine. + /// + Scan = 2, + } +} diff --git a/src/libraries/Microsoft.Extensions.Configuration/src/ReferenceParser.cs b/src/libraries/Microsoft.Extensions.Configuration/src/ReferenceParser.cs new file mode 100644 index 00000000000000..9ab48c72eec463 --- /dev/null +++ b/src/libraries/Microsoft.Extensions.Configuration/src/ReferenceParser.cs @@ -0,0 +1,281 @@ +// 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.Text; + +namespace Microsoft.Extensions.Configuration +{ + internal static class ReferenceParser + { + internal static bool ContainsReference(string? value) + { + if (value is null) + { + return false; + } + + for (int i = 0; i < value.Length - 1; i++) + { + if (value[i] != '$' || value[i + 1] != '{') + { + continue; + } + + // ${{ is a literal-${ shorthand; skip past the second '{' so we don't re-match on it. + if (i + 2 < value.Length && value[i + 2] == '{') + { + i += 2; + continue; + } + + return true; + } + + return false; + } + + internal static IReadOnlyList Parse(string value) + { + ArgumentNullException.ThrowIfNull(value); + + var tokens = new List(); + var literal = new StringBuilder(); + + int i = 0; + while (i < value.Length) + { + if (i < value.Length - 2 && value[i] == '$' && value[i + 1] == '{' && value[i + 2] == '{') + { + literal.Append("${"); + i += 3; + continue; + } + + if (i < value.Length - 1 && value[i] == '$' && value[i + 1] == '{') + { + if (literal.Length > 0) + { + tokens.Add(ValueToken.Literal(literal.ToString())); + literal.Clear(); + } + + int expressionStart = i + 2; + int expressionEnd = FindExpressionEnd(value, expressionStart, i); + string expression = value.Substring(expressionStart, expressionEnd - expressionStart); + + tokens.Add(ParseExpression(expression, i)); + + i = expressionEnd + 1; + continue; + } + + literal.Append(value[i]); + i++; + } + + if (literal.Length > 0) + { + tokens.Add(ValueToken.Literal(literal.ToString())); + } + + return tokens; + } + + private static int FindExpressionEnd(string value, int start, int tokenStart) + { + for (int j = start; j < value.Length; j++) + { + if (value[j] == '}') + { + return j; + } + } + + throw new FormatException(SR.Format(SR.ReferenceResolution_ExpressionIsUnclosed, tokenStart)); + } + + private static ValueToken ParseExpression(string expression, int tokenStart) + { + expression = expression.Trim(); + if (expression.Length == 0) + { + throw new FormatException(SR.Format(SR.ReferenceResolution_ExpressionIsEmpty, tokenStart)); + } + + // Trailing '!' marks a section reference as strict: the resulting section is exactly the + // aliased target, with no merging of earlier providers' keys under the aliased path. + bool isStrict = expression[expression.Length - 1] == '!'; + if (isStrict) + { + expression = expression.Substring(0, expression.Length - 1).TrimEnd(); + if (expression.Length == 0) + { + throw new FormatException(SR.Format(SR.ReferenceResolution_ExpressionIsEmpty, tokenStart)); + } + } + + // Trailing '?' marks the chain as optional: if all references fail to resolve, the expression + // collapses to empty string instead of throwing. Only valid as a terminal marker. + bool isOptional = expression[expression.Length - 1] == '?'; + if (isOptional) + { + expression = expression.Substring(0, expression.Length - 1).TrimEnd(); + if (expression.Length == 0) + { + throw new FormatException(SR.Format(SR.ReferenceResolution_ExpressionIsEmpty, tokenStart)); + } + } + + var items = new List(); + int i = 0; + while (i <= expression.Length) + { + while (i < expression.Length && char.IsWhiteSpace(expression[i])) + { + i++; + } + + if (i >= expression.Length) + { + throw new FormatException(SR.Format(SR.ReferenceResolution_KeyIsEmpty, tokenStart)); + } + + i = ParseReferenceItem(expression, i, tokenStart, items); + + while (i < expression.Length && char.IsWhiteSpace(expression[i])) + { + i++; + } + + if (i >= expression.Length) + { + break; + } + + if (expression[i] != '?') + { + throw new FormatException(SR.Format(SR.ReferenceResolution_InvalidExpression, tokenStart)); + } + + i++; + } + + return ValueToken.Reference(items, isOptional, isStrict); + } + + private static int ParseReferenceItem(string expression, int i, int tokenStart, List items) + { + int start = i; + while (i < expression.Length && expression[i] != '?') + { + i++; + } + + string refPath = expression.Substring(start, i - start).Trim(); + if (refPath.Length == 0) + { + throw new FormatException(SR.Format(SR.ReferenceResolution_KeyIsEmpty, tokenStart)); + } + + // Parse leading ".." segments as parent hops. The remaining text (if any) is an absolute + // suffix appended to the Nth ancestor of the literal's storage key at resolution time. + int parentHops = 0; + int cursor = 0; + while (cursor + 2 <= refPath.Length && refPath[cursor] == '.' && refPath[cursor + 1] == '.') + { + int next = cursor + 2; + if (next == refPath.Length) + { + parentHops++; + cursor = next; + break; + } + + if (refPath[next] != ConfigurationPath.KeyDelimiter[0]) + { + break; + } + + parentHops++; + cursor = next + 1; + } + + string suffix = cursor == 0 ? refPath : refPath.Substring(cursor); + + if (suffix.Contains("..")) + { + foreach (string segment in suffix.Split(ConfigurationPath.KeyDelimiter[0])) + { + if (segment == "..") + { + throw new FormatException(SR.Format(SR.ReferenceResolution_InvalidExpression, tokenStart)); + } + } + } + + if (parentHops == 0 && suffix.Length == 0) + { + throw new FormatException(SR.Format(SR.ReferenceResolution_KeyIsEmpty, tokenStart)); + } + + items.Add(new ReferenceItem(suffix, parentHops)); + return i; + } + } + + internal enum ValueTokenKind + { + Literal, + Reference, + } + + internal readonly struct ReferenceItem + { + public ReferenceItem(string path, int parentHops = 0) + { + Value = path; + ParentHops = parentHops; + } + + internal string Value { get; } + + internal int ParentHops { get; } + } + + internal readonly struct ValueToken + { + private static readonly IReadOnlyList s_noItems = Array.Empty(); + + private ValueToken(ValueTokenKind kind, string value, IReadOnlyList items, bool isOptional, bool isStrict) + { + Kind = kind; + Value = value; + Items = items; + IsOptional = isOptional; + IsStrict = isStrict; + } + + internal static ValueToken Literal(string text) => + new(ValueTokenKind.Literal, text, s_noItems, isOptional: false, isStrict: false); + + internal static ValueToken Reference(IReadOnlyList items, bool isOptional, bool isStrict = false) + { + string first = items.Count > 0 ? items[0].Value : string.Empty; + return new ValueToken(ValueTokenKind.Reference, first, items, isOptional, isStrict); + } + + internal ValueTokenKind Kind { get; } + + // For Literal tokens: the literal text. + // For Reference tokens: the first reference path (convenience). + internal string Value { get; } + + internal IReadOnlyList Items { get; } + + internal bool IsOptional { get; } + + internal bool IsStrict { get; } + } +} diff --git a/src/libraries/Microsoft.Extensions.Configuration/src/ReferenceResolutionConfigurationBuilderExtensions.cs b/src/libraries/Microsoft.Extensions.Configuration/src/ReferenceResolutionConfigurationBuilderExtensions.cs new file mode 100644 index 00000000000000..86c94982db9b36 --- /dev/null +++ b/src/libraries/Microsoft.Extensions.Configuration/src/ReferenceResolutionConfigurationBuilderExtensions.cs @@ -0,0 +1,190 @@ +// 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; + +namespace Microsoft.Extensions.Configuration +{ + /// + /// Provides extension methods for configuring how individual + /// instances participate in ${...} reference resolution performed by the + /// built from the containing + /// . + /// + /// + /// Sources default to . The reference-resolution engine is + /// attached to the built root only when at least one source is marked + /// ; otherwise the built root behaves as a plain + /// with no reference interpretation. + /// + public static class ReferenceResolutionConfigurationBuilderExtensions + { + // Signal placed in IConfigurationBuilder.Properties that carries the per-source mode + // overrides. The value is a Dictionary + // (reference-equality keys); at Build time it is correlated with the produced providers + // so the engine can apply the correct mode per provider without wrapping them. Sources + // without an entry default to ReferenceMode.Read. + internal const string SourceModesPropertyName = "Microsoft.Extensions.Configuration.ReferenceResolution.SourceModes"; + + /// + /// Sets the for the most recently added source on the + /// builder. + /// + /// The whose last source should be configured. + /// The mode to apply to the source. + /// The same . + /// The builder has no sources to configure. + public static IConfigurationBuilder SetReferenceMode(this IConfigurationBuilder configurationBuilder, ReferenceMode mode) + { + ArgumentNullException.ThrowIfNull(configurationBuilder); + + IList sources = configurationBuilder.Sources; + if (sources.Count == 0) + { + throw new InvalidOperationException(SR.ReferenceResolution_NoSourceToConfigure); + } + + SetSourceMode(configurationBuilder, sources[sources.Count - 1], mode); + return configurationBuilder; + } + + /// + /// Sets the for the specified source. Use this to configure + /// sources added by a host or other caller after they were registered. + /// + /// The containing the source. + /// The source to configure. Must already be present in . + /// The mode to apply to the source. + /// The same . + /// The specified source is not present in the builder. + public static IConfigurationBuilder SetReferenceMode(this IConfigurationBuilder configurationBuilder, IConfigurationSource source, ReferenceMode mode) + { + ArgumentNullException.ThrowIfNull(configurationBuilder); + ArgumentNullException.ThrowIfNull(source); + + if (!configurationBuilder.Sources.Contains(source)) + { + throw new ArgumentException(SR.ReferenceResolution_SourceNotFound, nameof(source)); + } + + SetSourceMode(configurationBuilder, source, mode); + return configurationBuilder; + } + + /// + /// Sets the for a batch of sources. Each source must already + /// be present in ; validation runs before any + /// change is applied, so the operation is atomic. + /// + /// The containing the sources. + /// The sources to configure. A enumerable is treated as empty. + /// The mode to apply to every source in . + /// The same . + /// A source in is or not present in the builder. + public static IConfigurationBuilder SetReferenceMode(this IConfigurationBuilder configurationBuilder, IEnumerable sources, ReferenceMode mode) + { + ArgumentNullException.ThrowIfNull(configurationBuilder); + + if (sources is null) + { + return configurationBuilder; + } + + // Materialize once and validate up-front so partial application is impossible. + var materialized = new List(); + foreach (IConfigurationSource source in sources) + { + if (source is null) + { + throw new ArgumentException(SR.ReferenceResolution_SourceNotFound, nameof(sources)); + } + if (!configurationBuilder.Sources.Contains(source)) + { + throw new ArgumentException(SR.ReferenceResolution_SourceNotFound, nameof(sources)); + } + materialized.Add(source); + } + + foreach (IConfigurationSource source in materialized) + { + SetSourceMode(configurationBuilder, source, mode); + } + + return configurationBuilder; + } + + internal static Dictionary? TryGetSourceModes(IDictionary properties) + { + if (properties is not null && properties.TryGetValue(SourceModesPropertyName, out object? raw)) + { + return raw as Dictionary; + } + + return null; + } + + // Projects the per-source mode overrides onto the produced providers by positional + // correspondence. Sources and providers share order: ConfigurationBuilder.Build iterates + // sources to produce providers, and ConfigurationManager tracks source additions one-to-one + // with provider additions. Providers whose source has no override are omitted — callers + // treat a missing key as ReferenceMode.Read. Mismatched counts return null. + internal static Dictionary? ResolveProviderModes( + IDictionary properties, + IList sources, + IReadOnlyList providers) + { + Dictionary? overrides = TryGetSourceModes(properties); + if (overrides is null || overrides.Count == 0 || sources.Count != providers.Count) + { + return null; + } + + Dictionary? result = null; + for (int i = 0; i < sources.Count; i++) + { + if (overrides.TryGetValue(sources[i], out ReferenceMode mode)) + { + result ??= new Dictionary(); + result[providers[i]] = mode; + } + } + + return result; + } + + // Checks whether at least one source in the builder is marked ReferenceMode.Scan. + // Used by the builder/manager to decide whether to attach the engine at Build time; + // when no source is Scan the feature is dormant and the root falls through to the + // normal provider walk. + internal static bool HasAnyScanSource(IDictionary properties) + { + Dictionary? overrides = TryGetSourceModes(properties); + if (overrides is null) + { + return false; + } + + foreach (ReferenceMode mode in overrides.Values) + { + if (mode == ReferenceMode.Scan) + { + return true; + } + } + + return false; + } + + private static void SetSourceMode(IConfigurationBuilder configurationBuilder, IConfigurationSource source, ReferenceMode mode) + { + // Dictionary is written back to Properties whether newly created or not so that + // builders whose Properties dictionary reacts to writes (e.g., ConfigurationManager) + // trigger a rebuild of the engine with the new mode map. + Dictionary map = TryGetSourceModes(configurationBuilder.Properties) + ?? new Dictionary(); + map[source] = mode; + configurationBuilder.Properties[SourceModesPropertyName] = map; + } + } +} diff --git a/src/libraries/Microsoft.Extensions.Configuration/src/ReferenceResolutionEngine.cs b/src/libraries/Microsoft.Extensions.Configuration/src/ReferenceResolutionEngine.cs new file mode 100644 index 00000000000000..fb8ea96303fe59 --- /dev/null +++ b/src/libraries/Microsoft.Extensions.Configuration/src/ReferenceResolutionEngine.cs @@ -0,0 +1,767 @@ +// 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.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading; +using Microsoft.Extensions.Primitives; + +namespace Microsoft.Extensions.Configuration +{ + // Root-owned resolution engine. Backs IConfigurationRoot.this[key] and GetChildren when a + // builder has marked at least one source with ReferenceMode.Scan. Holds the ordered provider + // set and the resolution cache; subscribes to the composite reload token so the cache is + // dropped whenever any inner provider reloads. + // + // Per-source modes (ReferenceMode): each provider carries a mode derived from its source's + // configuration. Default is Read. Providers with mode Ignore are invisible to the engine as + // substitution targets (GetRawValue skips them). Providers whose mode is not Scan are not + // parsed for ${...} — their values are returned sealed, so the engine returns them as + // literals. At least one provider must be in Scan mode for the engine to be attached at + // Build time. + internal sealed class ReferenceResolutionEngine : IDisposable + { + private const int MaxDepth = 1024; + private const int CycleCheckThreshold = 32; + + private static readonly IChangeToken s_neverChangedToken = new CancellationChangeToken(CancellationToken.None); + + private readonly ProviderSet _providers; + private readonly AliasFinder _aliasFinder; + private readonly ReferenceResolver _resolver; + private readonly IDisposable _changeTokenRegistration; + + private volatile ConcurrentDictionary _cache = new(StringComparer.OrdinalIgnoreCase); + + internal ReferenceResolutionEngine( + IReadOnlyList providers, + IReadOnlyDictionary? providerModes = null) + { + ArgumentNullException.ThrowIfNull(providers); + + _providers = new ProviderSet(providers, providerModes); + _aliasFinder = new AliasFinder(_providers); + _resolver = new ReferenceResolver(_providers, _aliasFinder); + _changeTokenRegistration = ChangeToken.OnChange(_providers.GetCompositeReloadToken, Invalidate); + } + + public bool TryGet(string key, out string? value) + { + ConcurrentDictionary cache = _cache; + var path = new Path(key); + + if (cache.TryGetValue(path.Value, out value)) + { + return true; + } + + bool hasTopLayer = _providers.TryGetTopLayer(path, out string? topValue, out int topIndex, out ReferenceMode topMode); + + // Literal short-circuit: the top provider is not scanned. Return its value as-is and + // skip alias discovery. Covers Ignore / Read uniformly — sources outside the scan set + // don't participate as alias targets in either direction. + if (hasTopLayer && topMode != ReferenceMode.Scan) + { + value = topValue; + cache.TryAdd(path.Value, value); + return true; + } + + Value raw = hasTopLayer + ? (topValue is null ? Value.Section(topIndex) : Value.Leaf(topValue, topIndex)) + : Value.Missing; + + // An ancestor section alias defined at a later provider shadows this key; + // rebase the key into the aliased target and read from there. If the alias target + // is empty (no value and no children), fall through to raw handling so a value from + // an earlier provider below the alias still surfaces. + if (_aliasFinder.TryFindAncestor(path, out SectionAlias alias) && + (!raw.Exists || alias.SourceIndex > raw.ProviderIndex)) + { + Path targetKey = alias.Rebase(path); + Value target = _providers.GetRawValue(targetKey, alias.SourceIndex); + + if (target.Exists) + { + if (target.NeedsResolving) + { + if (!_resolver.TryResolve(targetKey, target.AsString!, target.ProviderIndex, out value)) + { + // Soft fail: surface the unresolved literal from the alias target. + value = target.AsString; + } + } + else + { + value = target.AsString; + } + + cache.TryAdd(path.Value, value); + return true; + } + + if (_providers.GetDirectChildKeys(targetKey, alias.SourceIndex)?.Any() is true) + { + value = null; + cache.TryAdd(path.Value, value); + return true; + } + + // Alias target is empty. For a strict alias, do not fall through: earlier providers' + // values under the aliased path are hidden by construction. Return no value. + if (alias.Strict) + { + value = null; + cache.TryAdd(path.Value, value); + return false; + } + + // Non-strict: fall through; earlier providers' raw value (if any) wins. + } + + if (!raw.Exists) + { + value = null; + return false; + } + + if (!raw.NeedsResolving) + { + value = raw.AsString; + return true; + } + + // A single-token section alias at this exact key surfaces as a section (null leaf). + if (_aliasFinder.IsDirectSectionAlias(path, raw, out _)) + { + value = null; + cache.TryAdd(path.Value, value); + return true; + } + + if (!_resolver.TryResolve(path, raw.AsString!, _providers.LastIndex, out value)) + { + // Soft fail: surface the unresolved literal rather than forcing the caller to + // re-walk the providers. + value = raw.AsString; + return true; + } + + cache.TryAdd(path.Value, value); + return true; + } + + public IEnumerable GetChildKeys(IEnumerable earlierKeys, string? parentPath) + { + ArgumentNullException.ThrowIfNull(earlierKeys); + + if (string.IsNullOrEmpty(parentPath) || _providers.IsEmpty) + { + return earlierKeys; + } + + var parent = new Path(parentPath); + Value raw = _providers.GetRawValue(parent); + SectionAlias alias = SectionAlias.None; + + if (raw.NeedsResolving) + { + _aliasFinder.IsDirectSectionAlias(parent, raw, out alias); + } + + if (alias.IsEmpty && !_aliasFinder.TryFindAncestor(parent, out alias)) + { + return earlierKeys; + } + + Path targetParent = alias.Rebase(parent); + IEnumerable? aliasedChildren = _providers.GetDirectChildKeys(targetParent, alias.SourceIndex); + if (aliasedChildren is null) + { + return earlierKeys; + } + + // Dedupe while preserving first occurrence order, then sort via ConfigurationKeyComparer. + var merged = new List(); + merged.AddRange(aliasedChildren); + // For a strict alias, the aliased target is the sole source under this section. + // Discard earlierKeys (which may include contributions from providers at or below the + // alias) and re-merge only the children contributed by providers strictly above the + // alias — those later providers shadow the alias and must still participate. + if (alias.Strict) + { + IEnumerable? postAliasChildren = _providers.GetDirectChildKeysAbove(parent, alias.SourceIndex); + if (postAliasChildren is not null) + { + merged.AddRange(postAliasChildren); + } + } + else + { + merged.AddRange(earlierKeys); + } + + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + var result = new List(merged.Count); + foreach (string s in merged) + { + if (seen.Add(s)) + { + result.Add(s); + } + } + + result.Sort(ConfigurationKeyComparer.Comparison); + return result; + } + + public void Dispose() => _changeTokenRegistration.Dispose(); + + public void Invalidate() + { + _cache = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); + } + + private readonly struct ProviderSet + { + private readonly IConfigurationProvider[] _providers; + private readonly ReferenceMode[] _modes; + + public ProviderSet( + IReadOnlyList providers, + IReadOnlyDictionary? providerModes) + { + _providers = [.. providers]; + _modes = new ReferenceMode[_providers.Length]; + for (int i = 0; i < _providers.Length; i++) + { + _modes[i] = providerModes is not null && providerModes.TryGetValue(_providers[i], out ReferenceMode mode) + ? mode + : ReferenceMode.Read; + } + } + + public bool IsEmpty => _providers.Length == 0; + + public int LastIndex => _providers.Length - 1; + + private bool IsReadable(int index) => _modes[index] != ReferenceMode.Ignore; + + private bool IsScanned(int index) => _modes[index] == ReferenceMode.Scan; + + // Walks providers from upperIndex down to 0 and returns the first hit, SKIPPING + // providers whose mode lacks the Read flag. Used by substitution and alias scans + // so that values from non-readable sources never leak into resolved output. + // Values from providers without the Scan flag are returned sealed so the engine + // treats their ${...} text as literal. + public Value GetRawValue(Path key, int? upperIndex = null) + { + for (int i = upperIndex ?? LastIndex; i >= 0; i--) + { + if (!IsReadable(i)) + { + continue; + } + + if (_providers[i].TryGet(key.Value, out string? value)) + { + return value is null + ? Value.Section(i) + : Value.Leaf(value, i, isSealed: !IsScanned(i)); + } + } + + return Value.Missing; + } + + // Walks ALL providers top-down (regardless of mode) to find which provider owns the + // top-most hit at the engine's entry point. Returns true with the hit's value, index, + // and mode, false when no provider has the key. + public bool TryGetTopLayer(Path key, out string? value, out int providerIndex, out ReferenceMode mode) + { + for (int i = LastIndex; i >= 0; i--) + { + if (_providers[i].TryGet(key.Value, out string? v)) + { + value = v; + providerIndex = i; + mode = _modes[i]; + return true; + } + } + + value = null; + providerIndex = -1; + mode = ReferenceMode.Ignore; + return false; + } + + // Merges GetChildKeys across readable providers [0..upperIndex] in ascending order. + // Non-readable providers are skipped to keep the engine's merged child view consistent + // with GetRawValue's skipping behavior. + public IEnumerable? GetDirectChildKeys(Path parentPath, int upperIndex) + { + if (upperIndex < 0) + { + return null; + } + + IEnumerable childKeys = Array.Empty(); + for (int i = 0; i <= upperIndex; i++) + { + if (!IsReadable(i)) + { + continue; + } + + childKeys = _providers[i].GetChildKeys(childKeys, parentPath.Value); + } + + return childKeys; + } + + // Merges GetChildKeys across readable providers (lowerIndexExclusive..LastIndex]. + // Used by strict section aliases to admit only children contributed by providers + // strictly above the alias declaration. + public IEnumerable? GetDirectChildKeysAbove(Path parentPath, int lowerIndexExclusive) + { + int lastIndex = LastIndex; + if (lowerIndexExclusive >= lastIndex) + { + return null; + } + + IEnumerable childKeys = Array.Empty(); + for (int i = lowerIndexExclusive + 1; i <= lastIndex; i++) + { + if (!IsReadable(i)) + { + continue; + } + + childKeys = _providers[i].GetChildKeys(childKeys, parentPath.Value); + } + + return childKeys; + } + + // A path is a "section" if it has no direct value but does have children. + public bool IsSectionPath(Path path, int upperIndex) + { + Value raw = GetRawValue(path, upperIndex); + if (raw.Exists) + { + return raw.IsSection; + } + + return GetDirectChildKeys(path, upperIndex)?.Any() is true; + } + + public IChangeToken GetCompositeReloadToken() + { + var tokens = new List(_providers.Length); + + foreach (IConfigurationProvider provider in _providers) + { + IChangeToken token = provider.GetReloadToken(); + if (token is not null) + { + tokens.Add(token); + } + } + + return tokens.Count switch + { + 0 => s_neverChangedToken, + 1 => tokens[0], + _ => new CompositeChangeToken(tokens), + }; + } + } + + private readonly struct AliasFinder + { + private readonly ProviderSet _providers; + + public AliasFinder(ProviderSet providers) + { + _providers = providers; + } + + // Detects whether `raw` at `path` is a single-token section alias (e.g. ${other:section}). + public bool IsDirectSectionAlias(Path path, Value raw, out SectionAlias alias) + { + if (!raw.IsLeaf) + { + alias = SectionAlias.None; + return false; + } + + IReadOnlyList tokens = ReferenceParser.Parse(raw.AsString!); + + if (tokens.Count == 1 && + tokens[0].Kind == ValueTokenKind.Reference && + TryDetectSectionTarget(tokens[0], raw.ProviderIndex, out Path targetPath)) + { + alias = new SectionAlias(path, targetPath, raw.ProviderIndex, tokens[0].IsStrict); + return true; + } + + alias = SectionAlias.None; + return false; + } + + // Walks ancestors of `key` looking for a section alias that rebases the key. + public bool TryFindAncestor(Path key, out SectionAlias alias) + { + int lastIndex = _providers.LastIndex; + + Path current = key; + while (current.TryGetParent(out Path ancestor)) + { + Value raw = _providers.GetRawValue(ancestor, lastIndex); + if (raw.NeedsResolving && IsDirectSectionAlias(ancestor, raw, out alias)) + { + return true; + } + + current = ancestor; + } + + alias = SectionAlias.None; + return false; + } + + // A token targets a section if any reference item in its chain resolves to a section + // (order preserved; the first matching reference wins). + private bool TryDetectSectionTarget(ValueToken token, int sourceProviderIndex, out Path targetPath) + { + foreach (ReferenceItem item in token.Items) + { + var candidate = new Path(item.Value); + if (!candidate.IsEmpty && _providers.IsSectionPath(candidate, sourceProviderIndex)) + { + targetPath = candidate; + return true; + } + } + + targetPath = default; + return false; + } + + // Shadowing lookup via ancestor section alias. Returns true with the aliased `result` and the + // `effectivePath` where it physically lives when: + // - an ancestor of `key` is a section alias visible within `upperIndex`, AND + // - either `raw` is missing or the alias was declared at a later provider (shadowing rule), AND + // - the rebased target has a direct value or children. + // Returns false when no alias applies or the alias target is absent (caller uses `raw`). + public bool TryResolveViaAncestor(Path key, int upperIndex, Value raw, out Value result, out Path effectivePath) + { + if (TryFindAncestor(key, out SectionAlias alias) && + alias.SourceIndex <= upperIndex && + (!raw.Exists || alias.SourceIndex > raw.ProviderIndex)) + { + effectivePath = alias.Rebase(key); + Value target = _providers.GetRawValue(effectivePath, alias.SourceIndex); + if (target.Exists) + { + result = target; + return true; + } + + if (_providers.GetDirectChildKeys(effectivePath, alias.SourceIndex)?.Any() is true) + { + result = Value.Section(alias.SourceIndex); + return true; + } + } + + result = default; + effectivePath = default; + return false; + } + } + + private readonly struct ReferenceResolver + { + private readonly ProviderSet _providers; + private readonly AliasFinder _aliasFinder; + + public ReferenceResolver(ProviderSet providers, AliasFinder aliasFinder) + { + _providers = providers; + _aliasFinder = aliasFinder; + } + + public bool TryResolve(Path originKey, string rawValue, int upperIndex, out string? value) + => TryResolveValue(originKey, rawValue, resolutionStack: null, depth: 0, upperIndex, out value); + + private bool TryResolveValue(Path originKey, string rawValue, HashSet? resolutionStack, int depth, int upperIndex, out string? value) + { + if (depth > MaxDepth) + { + throw new InvalidOperationException(SR.Format(SR.ReferenceResolution_MaxDepthExceeded, MaxDepth, originKey)); + } + + // Only start paying for cycle tracking once recursion is deep enough to suggest a loop. + if (depth >= CycleCheckThreshold && resolutionStack is null) + { + resolutionStack = new HashSet(); + } + + if (resolutionStack is not null && !resolutionStack.Add(originKey)) + { + throw new InvalidOperationException(SR.Format(SR.ReferenceResolution_CircularReference, originKey)); + } + + try + { + IReadOnlyList tokens = ReferenceParser.Parse(rawValue); + if (tokens.Count == 1 && tokens[0].Kind == ValueTokenKind.Reference) + { + return TryResolveToken(originKey, tokens[0], resolutionStack, depth + 1, upperIndex, out value); + } + + var builder = new StringBuilder(); + foreach (ValueToken token in tokens) + { + if (token.Kind == ValueTokenKind.Literal) + { + builder.Append(token.Value); + continue; + } + + if (!TryResolveToken(originKey, token, resolutionStack, depth + 1, upperIndex, out string? resolvedTokenValue)) + { + value = null; + return false; + } + + builder.Append(resolvedTokenValue ?? string.Empty); + } + + value = builder.ToString(); + return true; + } + finally + { + resolutionStack?.Remove(originKey); + } + } + + private bool TryResolveToken(Path storageKey, ValueToken token, HashSet? resolutionStack, int depth, int upperIndex, out string? value) + { + foreach (ReferenceItem item in token.Items) + { + if (!TryBuildAbsolutePath(storageKey, item, out Path tokenPath)) + { + continue; + } + + Value raw = _providers.GetRawValue(tokenPath, upperIndex); + + // Honor ancestor section aliases so tokens inside a value see the same key space as + // direct reads. Matches TryGet's shadowing rules: an alias at a later provider wins + // over a raw value at an earlier one; a missing alias target falls back to `raw`. + if (_aliasFinder.TryResolveViaAncestor(tokenPath, upperIndex, raw, out Value aliased, out _)) + { + raw = aliased; + } + + if (!raw.Exists) + { + continue; + } + + if (raw.NeedsResolving) + { + return TryResolveValue(tokenPath, raw.AsString!, resolutionStack, depth, raw.ProviderIndex, out value); + } + + value = raw.AsString; + return true; + } + + if (token.IsOptional) + { + value = string.Empty; + return true; + } + + value = null; + return false; + } + + private static bool TryBuildAbsolutePath(Path storageKey, ReferenceItem item, out Path absolute) + { + if (item.ParentHops == 0) + { + absolute = new Path(item.Value); + return true; + } + + if (!storageKey.TryGetAncestor(item.ParentHops, out Path anchor)) + { + absolute = Path.Empty; + return false; + } + + absolute = item.Value.Length == 0 ? anchor : anchor.Combine(item.Value); + return true; + } + } + + private readonly struct Path : IComparable, IEquatable + { + public static Path Empty => default; + + public string Value { get; } + + public int Length => Value?.Length ?? 0; + + public bool IsEmpty => string.IsNullOrEmpty(Value); + + public Path(string value) + { + Value = value.Trim().Replace('.', ConfigurationPath.KeyDelimiter[0]); + } + + public bool IsParentOf(Path candidate) + { + return candidate.Length > Length && + candidate.Value.StartsWith(Value, StringComparison.OrdinalIgnoreCase) && + candidate.Value[Length] == ConfigurationPath.KeyDelimiter[0]; + } + + public bool IsEqualTo(Path candidate) => Equals(candidate); + + public bool IsChildOf(Path candidate) => candidate.IsParentOf(this); + + public Path Combine(string child) + { + if (IsEmpty) + { + return new Path(child); + } + + return new Path(Value + ConfigurationPath.KeyDelimiter + child); + } + + public bool TryGetParent(out Path parent) + { + int idx = Value?.LastIndexOf(ConfigurationPath.KeyDelimiter[0]) ?? -1; + if (idx < 0) + { + parent = Empty; + return false; + } + + parent = new Path(Value!.Substring(0, idx)); + return true; + } + + public bool TryGetAncestor(int hops, out Path ancestor) + { + ancestor = this; + for (int n = 0; n < hops; n++) + { + if (!ancestor.TryGetParent(out Path next)) + { + ancestor = Empty; + return false; + } + + ancestor = next; + } + + return true; + } + + public Path Rebase(Path fromRoot, Path toRoot) + { + if (Length == fromRoot.Length) + { + return toRoot; + } + + return new Path(toRoot.Value + Value.Substring(fromRoot.Length)); + } + + public bool Equals(Path other) => string.Equals(Value, other.Value, StringComparison.OrdinalIgnoreCase); + + public override bool Equals(object? obj) => obj is Path other && Equals(other); + + public override int GetHashCode() => Value is null ? 0 : StringComparer.OrdinalIgnoreCase.GetHashCode(Value); + + public override string ToString() => Value ?? string.Empty; + + public int CompareTo(Path other) => ConfigurationKeyComparer.Instance.Compare(Value, other.Value); + } + + private readonly struct SectionAlias + { + public static SectionAlias None => new(default, default, -1, strict: false); + + public Path Source { get; } + + public Path Target { get; } + + public int SourceIndex { get; } + + public bool Strict { get; } + + public SectionAlias(Path source, Path target, int sourceIndex, bool strict) + { + Source = source; + Target = target; + SourceIndex = sourceIndex; + Strict = strict; + } + + public bool IsEmpty => SourceIndex < 0; + + public Path Rebase(Path key) => key.Rebase(Source, Target); + } + + private readonly struct Value + { + public static Value Missing => default; + + public static Value Section(int providerIndex) => new(null, providerIndex, isLeaf: false, exists: true, isSealed: false); + + public static Value Leaf(string? value, int providerIndex, bool isSealed = false) => new(value, providerIndex, isLeaf: true, exists: true, isSealed: isSealed); + + private Value(string? value, int providerIndex, bool isLeaf, bool exists, bool isSealed) + { + AsString = value; + ProviderIndex = providerIndex; + IsLeaf = isLeaf; + Exists = exists; + IsSealed = isSealed; + } + + public string? AsString { get; } + + public int ProviderIndex { get; } + + public bool Exists { get; } + + public bool IsLeaf { get; } + + // True when the value came from another ReferenceResolutionConfigurationProvider and is + // already fully resolved. Later reference-resolution providers treat it as a literal and do + // not rescan it for tokens. + public bool IsSealed { get; } + + public bool IsSection => Exists && !IsLeaf; + + public bool NeedsResolving => + IsLeaf && !IsSealed && AsString is not null && + (ReferenceParser.ContainsReference(AsString) || AsString.Contains("${{")); + } + } +} diff --git a/src/libraries/Microsoft.Extensions.Configuration/src/Resources/Strings.resx b/src/libraries/Microsoft.Extensions.Configuration/src/Resources/Strings.resx index 4a1f5628be6829..eb29ec794c3122 100644 --- a/src/libraries/Microsoft.Extensions.Configuration/src/Resources/Strings.resx +++ b/src/libraries/Microsoft.Extensions.Configuration/src/Resources/Strings.resx @@ -129,4 +129,34 @@ Source.Stream cannot be null. - \ No newline at end of file + + A circular reference was detected while resolving '{0}'. + + + A reference expression at position '{0}' is empty. + + + An unclosed reference expression was found at position '{0}'. + + + A reference expression at position '{0}' is malformed. Expected '?' between items or end of expression. + + + A reference expression at position '{0}' has an empty key. + + + The reference key '{0}' referenced by '{1}' was not found. Add a fallback reference or mark the chain optional with a trailing '?' (for example '${{Key?}}'). + + + Maximum reference-resolution depth '{0}' was exceeded while resolving '{1}'. + + + Section reference at '{0}' must be a single reference expression in the form '${{Path}}'. + + + The configuration builder has no sources; there is nothing to configure for reference resolution. + + + The specified configuration source is not present in the builder. + + diff --git a/src/libraries/Microsoft.Extensions.Configuration/tests/ConfigurationManagerTest.cs b/src/libraries/Microsoft.Extensions.Configuration/tests/ConfigurationManagerTest.cs index e84f51b2c457fb..3fcca306d57367 100644 --- a/src/libraries/Microsoft.Extensions.Configuration/tests/ConfigurationManagerTest.cs +++ b/src/libraries/Microsoft.Extensions.Configuration/tests/ConfigurationManagerTest.cs @@ -96,6 +96,147 @@ public void SettingIConfigurationBuilderPropertiesReloadsSources() Assert.False(reloadToken2.HasChanged); } + [Fact] + public void EnableReferenceResolutionResolvesValues() + { + var config = new ConfigurationManager(); + IConfigurationBuilder builder = config; + + builder.AddInMemoryCollection(new Dictionary + { + ["Host"] = "api.example.com", + ["ServiceUrl"] = "https://${Host}", + }); + + builder.EnableReferenceResolution(); + + Assert.Equal("https://api.example.com", config["ServiceUrl"]); + } + + [Fact] + public void EnableReferenceResolutionDoesNotReloadExistingSources() + { + var config = new ConfigurationManager(); + IConfigurationBuilder builder = config; + + var counter = new LoadCountingProvider(new Dictionary + { + ["Host"] = "api.example.com", + ["ServiceUrl"] = "https://${Host}", + }); + + builder.Add(new LoadCountingSource(counter)); + Assert.Equal(1, counter.LoadCount); + + builder.EnableReferenceResolution(); + Assert.Equal(1, counter.LoadCount); + + builder.ConfigureReferenceResolution(ReferenceMode.Read); + Assert.Equal(1, counter.LoadCount); + + // And the engine picked up the mode change for the existing provider. + Assert.Equal("https://${Host}", config["ServiceUrl"]); + } + + [Fact] + public void EnableReferenceResolutionObservesLaterAddedSources() + { + var config = new ConfigurationManager(); + IConfigurationBuilder builder = config; + + builder.AddInMemoryCollection(new Dictionary + { + ["ServiceUrl"] = "https://${Host?}fallback", + }); + builder.EnableReferenceResolution(); + + // Sources added after EnableReferenceResolution do participate in resolution; + // the engine is rebuilt whenever the source list changes. + builder.AddInMemoryCollection(new Dictionary + { + ["Host"] = "api.example.com", + }); + + Assert.Equal("https://api.example.comfallback", config["ServiceUrl"]); + Assert.Equal("api.example.com", config["Host"]); + } + + [Fact] + public void EnableReferenceResolutionProjectsSectionReferenceChildren() + { + var config = new ConfigurationManager(); + IConfigurationBuilder builder = config; + + builder.AddInMemoryCollection(new Dictionary + { + ["Defaults:Feature:Enabled"] = "true", + ["Feature"] = "${Defaults:Feature}", + }); + builder.EnableReferenceResolution(); + + Assert.Null(config["Feature"]); + Assert.Equal("true", config["Feature:Enabled"]); + Assert.Equal("Enabled", config.GetSection("Feature").GetChildren().Single().Key); + } + + [Fact] + public void EnableReferenceResolutionProjectsNestedSectionReferenceChildren() + { + var config = new ConfigurationManager(); + IConfigurationBuilder builder = config; + + builder.AddInMemoryCollection(new Dictionary + { + ["Defaults:Feature:Nested:Enabled"] = "true", + ["Feature"] = "${Defaults:Feature}", + }); + builder.EnableReferenceResolution(); + + Assert.Equal("true", config["Feature:Nested:Enabled"]); + Assert.Equal("Enabled", config.GetSection("Feature:Nested").GetChildren().Single().Key); + } + + [Fact] + public void EnableReferenceResolutionKeepsSingleTokenReferenceAsLeafWhenTargetIsValue() + { + var config = new ConfigurationManager(); + IConfigurationBuilder builder = config; + + builder.AddInMemoryCollection(new Dictionary + { + ["Defaults:Feature"] = "feature-default", + ["Feature"] = "${Defaults:Feature}", + }); + builder.EnableReferenceResolution(); + + Assert.Equal("feature-default", config["Feature"]); + Assert.Null(config["Feature:Enabled"]); + Assert.Empty(config.GetSection("Feature").GetChildren()); + } + + [Fact] + public void EnableReferenceResolutionMergesEarlierChildrenNotPresentInAliasedSection() + { + var config = new ConfigurationManager(); + IConfigurationBuilder builder = config; + + builder.AddInMemoryCollection(new Dictionary + { + ["Feature:Legacy"] = "from-early-provider", + ["Defaults:Feature:Enabled"] = "from-defaults", + }); + builder.AddInMemoryCollection(new Dictionary + { + ["Feature"] = "${Defaults:Feature}", + }); + builder.EnableReferenceResolution(); + + Assert.Equal("from-early-provider", config["Feature:Legacy"]); + Assert.Equal( + new[] { "Enabled", "Legacy" }, + config.GetSection("Feature").GetChildren().Select(c => c.Key).OrderBy(k => k)); + } + [Fact] public void DisposesProvidersOnDispose() { @@ -1249,6 +1390,34 @@ public TestConfigurationProvider(string key, string value) => Data.Add(key, value); } + private sealed class LoadCountingSource : IConfigurationSource + { + private readonly IConfigurationProvider _provider; + + public LoadCountingSource(IConfigurationProvider provider) => _provider = provider; + + public IConfigurationProvider Build(IConfigurationBuilder builder) => _provider; + } + + private sealed class LoadCountingProvider : ConfigurationProvider + { + private readonly IDictionary _seed; + + public LoadCountingProvider(IDictionary seed) => _seed = seed; + + public int LoadCount { get; private set; } + + public override void Load() + { + LoadCount++; + Data.Clear(); + foreach (KeyValuePair kv in _seed) + { + Data[kv.Key] = kv.Value; + } + } + } + private class BlockLoadOnMREProvider : ConfigurationProvider { private readonly ManualResetEventSlim _mre; diff --git a/src/libraries/Microsoft.Extensions.Configuration/tests/ConfigurationTest.cs b/src/libraries/Microsoft.Extensions.Configuration/tests/ConfigurationTest.cs index 94ba8c20a9686c..bfd9b95454ea1a 100644 --- a/src/libraries/Microsoft.Extensions.Configuration/tests/ConfigurationTest.cs +++ b/src/libraries/Microsoft.Extensions.Configuration/tests/ConfigurationTest.cs @@ -602,6 +602,714 @@ public void SetValueThrowsExceptionNoSourceRegistered() Assert.Equal(expectedMsg, ex.Message); } + [Fact] + public void ReferenceResolutionIsDisabledByDefault() + { + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["BaseUrl"] = "https://example.com", + ["ServiceUrl"] = "${BaseUrl}/api", + }) + .Build(); + + Assert.Equal("${BaseUrl}/api", config["ServiceUrl"]); + } + + [Fact] + public void EnableReferenceResolutionResolvesValues() + { + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["BaseUrl"] = "https://example.com", + ["ServiceUrl"] = "${BaseUrl}/api", + }) + .EnableReferenceResolution() + .Build(); + + Assert.Equal("https://example.com/api", config["ServiceUrl"]); + } + + [Fact] + public void EnableReferenceResolutionUsesLastProviderValue() + { + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Environment"] = "development", + ["ServiceUrl"] = "https://${Environment}.example.com", + }) + .AddInMemoryCollection(new Dictionary + { + ["Environment"] = "production", + }) + .EnableReferenceResolution() + .Build(); + + Assert.Equal("https://production.example.com", config["ServiceUrl"]); + } + + [Fact] + public void EnableReferenceResolutionLeavesValueWhenRequiredReferenceIsMissing() + { + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["ServiceUrl"] = "https://${Host}.example.com", + }) + .EnableReferenceResolution() + .Build(); + + Assert.Equal("https://${Host}.example.com", config["ServiceUrl"]); + } + + [Theory] + [InlineData("${{Host}", "${Host}")] + [InlineData("prefix-${{Host}-suffix", "prefix-${Host}-suffix")] + [InlineData("${{A}-${Host}-${{B}", "${A}-my-host-${B}")] + [InlineData("$${Host}", "$my-host")] + [InlineData("It costs $${Host}", "It costs $my-host")] + [InlineData("${{}", "${}")] + public void EnableReferenceResolutionTreatsEscapeBlockAsLiteral(string raw, string expected) + { + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Host"] = "my-host", + ["Value"] = raw, + }) + .EnableReferenceResolution() + .Build(); + + Assert.Equal(expected, config["Value"]); + } + + [Fact] + public void EnableReferenceResolutionProjectsSectionReferenceChildren() + { + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Defaults:Feature:Enabled"] = "true", + ["Defaults:Feature:Name"] = "feature-default", + ["Feature"] = "${Defaults:Feature}", + }) + .EnableReferenceResolution() + .Build(); + + Assert.Null(config["Feature"]); + Assert.Equal("true", config["Feature:Enabled"]); + Assert.Equal("feature-default", config["Feature:Name"]); + Assert.Equal( + new[] { "Enabled", "Name" }, + config.GetSection("Feature").GetChildren().Select(c => c.Key).OrderBy(k => k)); + } + + [Fact] + public void EnableReferenceResolutionProjectsNestedSectionReferenceChildren() + { + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Defaults:Feature:Nested:Enabled"] = "true", + ["Feature"] = "${Defaults:Feature}", + }) + .EnableReferenceResolution() + .Build(); + + Assert.Equal("true", config["Feature:Nested:Enabled"]); + Assert.Equal("Enabled", config.GetSection("Feature:Nested").GetChildren().Single().Key); + } + + [Fact] + public void EnableReferenceResolutionKeepsSingleTokenReferenceAsLeafWhenTargetIsValue() + { + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Defaults:Feature"] = "feature-default", + ["Feature"] = "${Defaults:Feature}", + }) + .EnableReferenceResolution() + .Build(); + + Assert.Equal("feature-default", config["Feature"]); + Assert.Null(config["Feature:Enabled"]); + Assert.Empty(config.GetSection("Feature").GetChildren()); + } + + [Fact] + public void EnableReferenceResolutionOverridesValuesBeforeSectionReferenceProvider() + { + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Feature:Enabled"] = "from-early-provider", + ["Defaults:Feature:Enabled"] = "from-defaults", + }) + .AddInMemoryCollection(new Dictionary + { + ["Feature"] = "${Defaults:Feature}", + }) + .EnableReferenceResolution() + .Build(); + + Assert.Equal("from-defaults", config["Feature:Enabled"]); + } + + [Fact] + public void EnableReferenceResolutionResolvesTokenViaAncestorSectionAlias() + { + // ${Services:Primary:Host} in ConnectionString must follow the section alias just like a + // direct read of config["Services:Primary:Host"] would. + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Defaults:Service:Host"] = "primary.example.com", + ["Defaults:Service:Port"] = "5432", + ["Services:Primary"] = "${Defaults:Service}", + ["ConnectionString"] = "${Services:Primary:Host}:${Services:Primary:Port}", + }) + .EnableReferenceResolution() + .Build(); + + Assert.Equal("primary.example.com:5432", config["ConnectionString"]); + } + + [Fact] + public void EnableReferenceResolutionTokenViaAncestorAliasRespectsShadowing() + { + // Alias at a later provider shadows a direct value at an earlier provider, both for + // direct reads and for tokens embedded in values. + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Services:Primary:Host"] = "direct-literal", + ["Defaults:Service:Host"] = "aliased-target", + }) + .AddInMemoryCollection(new Dictionary + { + ["Services:Primary"] = "${Defaults:Service}", + ["ConnectionString"] = "host=${Services:Primary:Host}", + }) + .EnableReferenceResolution() + .Build(); + + Assert.Equal("aliased-target", config["Services:Primary:Host"]); + Assert.Equal("host=aliased-target", config["ConnectionString"]); + } + + [Fact] + public void EnableReferenceResolutionMergesEarlierChildrenNotPresentInAliasedSection() + { + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Feature:Legacy"] = "from-early-provider", + ["Defaults:Feature:Enabled"] = "from-defaults", + }) + .AddInMemoryCollection(new Dictionary + { + ["Feature"] = "${Defaults:Feature}", + }) + .EnableReferenceResolution() + .Build(); + + Assert.Equal("from-early-provider", config["Feature:Legacy"]); + Assert.Equal( + new[] { "Enabled", "Legacy" }, + config.GetSection("Feature").GetChildren().Select(c => c.Key).OrderBy(k => k)); + } + + [Fact] + public void EnableReferenceResolutionStrictAliasHidesEarlierChildrenUnderAliasedSection() + { + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Feature:Legacy"] = "from-early-provider", + ["Defaults:Feature:Enabled"] = "from-defaults", + }) + .AddInMemoryCollection(new Dictionary + { + ["Feature"] = "${Defaults:Feature!}", + }) + .EnableReferenceResolution() + .Build(); + + Assert.Null(config["Feature:Legacy"]); + Assert.Equal("from-defaults", config["Feature:Enabled"]); + Assert.Equal( + new[] { "Enabled" }, + config.GetSection("Feature").GetChildren().Select(c => c.Key).OrderBy(k => k)); + } + + [Fact] + public void EnableReferenceResolutionStrictAliasAllowsLaterProviderToShadow() + { + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Feature:Legacy"] = "from-early-provider", + ["Defaults:Feature:Enabled"] = "from-defaults", + }) + .AddInMemoryCollection(new Dictionary + { + ["Feature"] = "${Defaults:Feature!}", + }) + .AddInMemoryCollection(new Dictionary + { + ["Feature:Extra"] = "from-late-provider", + }) + .EnableReferenceResolution() + .Build(); + + Assert.Null(config["Feature:Legacy"]); + Assert.Equal("from-defaults", config["Feature:Enabled"]); + Assert.Equal("from-late-provider", config["Feature:Extra"]); + Assert.Equal( + new[] { "Enabled", "Extra" }, + config.GetSection("Feature").GetChildren().Select(c => c.Key).OrderBy(k => k)); + } + + [Fact] + public void EnableReferenceResolutionPreservesValuesAfterSectionReferenceProvider() + { + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Defaults:Feature:Enabled"] = "from-defaults", + }) + .AddInMemoryCollection(new Dictionary + { + ["Feature"] = "${Defaults:Feature}", + }) + .AddInMemoryCollection(new Dictionary + { + ["Feature:Enabled"] = "from-late-provider", + }) + .EnableReferenceResolution() + .Build(); + + Assert.Equal("from-late-provider", config["Feature:Enabled"]); + } + + [Fact] + public void EnableReferenceResolutionTreatsInterpolatedSectionReferenceAsLeafValue() + { + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Defaults:Feature:Enabled"] = "true", + ["Feature"] = "prefix-${Defaults:Feature}", + }) + .EnableReferenceResolution() + .Build(); + + Assert.Equal("prefix-${Defaults:Feature}", config["Feature"]); + Assert.Empty(config.GetSection("Feature").GetChildren()); + } + + [Fact] + public void EnableReferenceResolutionAppliesToAllSourcesRegardlessOfOrder() + { + // EnableReferenceResolution is a root-level signal, not a source. Sources added + // after the call still participate in substitution. + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["ServiceUrl"] = "https://${Host?}fallback", + }) + .EnableReferenceResolution() + .AddInMemoryCollection(new Dictionary + { + ["Host"] = "api.example.com", + }) + .Build(); + + Assert.Equal("https://api.example.comfallback", config["ServiceUrl"]); + Assert.Equal("api.example.com", config["Host"]); + } + + [Fact] + public void EnableReferenceResolutionIsIdempotent() + { + // Calling EnableReferenceResolution repeatedly keeps the single shared engine; + // there is no longer a per-call resolution "horizon". + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["name"] = "Alice", + ["greeting"] = "Hello ${name}", + }) + .EnableReferenceResolution() + .AddInMemoryCollection(new Dictionary + { + ["name"] = "Bob", + ["farewell"] = "Bye ${name}", + }) + .EnableReferenceResolution() + .Build(); + + Assert.Equal("Bob", config["name"]); + Assert.Equal("Hello Bob", config["greeting"]); + Assert.Equal("Bye Bob", config["farewell"]); + } + + [Fact] + public void EnableReferenceResolutionEscapeIsStillALiteral() + { + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Template"] = "${{hidden}", + ["hidden"] = "secret", + }) + .EnableReferenceResolution() + .Build(); + + Assert.Equal("${hidden}", config["Template"]); + } + + [Fact] + public void EnableReferenceResolutionOptionalChainCollapsesToEmpty() + { + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["ServiceUrl"] = "https://host${Suffix?}", + }) + .EnableReferenceResolution() + .Build(); + + Assert.Equal("https://host", config["ServiceUrl"]); + } + + [Fact] + public void EnableReferenceResolutionChainTriesReferencesInOrder() + { + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Primary"] = "first", + ["Secondary"] = "second", + ["Fallback"] = "fallback", + ["A"] = "${Missing?Primary}", + ["B"] = "${Missing?AlsoMissing?Secondary}", + ["C"] = "${Missing?AlsoMissing?Fallback}", + }) + .EnableReferenceResolution() + .Build(); + + Assert.Equal("first", config["A"]); + Assert.Equal("second", config["B"]); + Assert.Equal("fallback", config["C"]); + } + + [Fact] + public void EnableReferenceResolutionChainFirstPresentWins() + { + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Primary"] = "primary-value", + ["Secondary"] = "secondary-value", + ["Fallback"] = "fallback", + ["Value"] = "${Primary?Secondary?Fallback}", + }) + .EnableReferenceResolution() + .Build(); + + Assert.Equal("primary-value", config["Value"]); + } + + [Fact] + public void EnableReferenceResolutionOptionalChainWithAllMissingCollapsesToEmpty() + { + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Value"] = "${A?B?C?}", + }) + .EnableReferenceResolution() + .Build(); + + Assert.Equal("", config["Value"]); + } + + [Fact] + public void EnableReferenceResolutionOptionalChainPrefersFirstResolvedReference() + { + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Primary"] = "hit", + ["Value"] = "${Missing?Primary?}", + }) + .EnableReferenceResolution() + .Build(); + + Assert.Equal("hit", config["Value"]); + } + + [Theory] + [InlineData("${}")] + [InlineData("${ }")] + [InlineData("${?A}")] + [InlineData("${?}")] + [InlineData("${A??}")] + public void EnableReferenceResolutionMalformedExpressionThrows(string raw) + { + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Value"] = raw, + }) + .EnableReferenceResolution() + .Build(); + + Assert.Throws(() => _ = config["Value"]); + } + + [Fact] + public void EnableReferenceResolutionRelativeRefSiblingResolves() + { + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Services:Billing:Host"] = "billing.example.com", + ["Services:Billing:Url"] = "https://${..:Host}/api", + }) + .EnableReferenceResolution() + .Build(); + + Assert.Equal("https://billing.example.com/api", config["Services:Billing:Url"]); + } + + [Fact] + public void EnableReferenceResolutionRelativeRefUncleResolves() + { + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["App:Shared:Region"] = "eu-west", + ["App:Services:Billing:Zone"] = "${..:..:..:Shared:Region}", + }) + .EnableReferenceResolution() + .Build(); + + // Three hops up from App:Services:Billing:Zone = App, then :Shared:Region. + Assert.Equal("eu-west", config["App:Services:Billing:Zone"]); + } + + [Fact] + public void EnableReferenceResolutionRelativeRefAnchorsAtStorageNotQuery() + { + // A section alias maps Services:Billing onto the Defaults subtree. A relative ref + // inside a Defaults value must resolve relative to its storage key (Defaults:Db:Conn), + // not relative to the user-facing query key (Services:Billing:Db:Conn). + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Defaults:Db:Host"] = "defaults-db-host", + ["Defaults:Db:Conn"] = "${..:Host}", + ["Services:Billing:Db:Host"] = "billing-db-host", + ["Services:Billing"] = "${Defaults}", + }) + .EnableReferenceResolution() + .Build(); + + // ..:Host anchors at Defaults:Db (storage), so it picks Defaults:Db:Host even when + // queried via the aliased path. The user's Services:Billing:Db:Host value (which + // exists at the query side) is irrelevant to this resolution. + Assert.Equal("defaults-db-host", config["Services:Billing:Db:Conn"]); + } + + [Fact] + public void EnableReferenceResolutionRelativeRefAboveRootFallsThroughChain() + { + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Fallback"] = "fallback-value", + ["TopLevel"] = "${..:..:DoesNotExist?Fallback}", + }) + .EnableReferenceResolution() + .Build(); + + // The relative ref wants two parents above a 1-segment key; that fails, chain falls + // through to the absolute Fallback reference. + Assert.Equal("fallback-value", config["TopLevel"]); + } + + [Fact] + public void EnableReferenceResolutionRelativeRefAboveRootOptionalYieldsEmpty() + { + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["TopLevel"] = "prefix-${..:..:Nothing?}-suffix", + }) + .EnableReferenceResolution() + .Build(); + + Assert.Equal("prefix--suffix", config["TopLevel"]); + } + + [Fact] + public void EnableReferenceResolutionRelativeRefPureParentResolvesSection() + { + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Defaults:Host"] = "h", + ["Defaults:Port"] = "p", + ["Services:Db"] = "${..:..:Defaults}", + }) + .EnableReferenceResolution() + .Build(); + + // ..:..:Defaults from Services:Db = Defaults (section). Rebase exposes its children. + Assert.Equal("h", config["Services:Db:Host"]); + Assert.Equal("p", config["Services:Db:Port"]); + } + + [Theory] + [InlineData("${A:..:B}")] + [InlineData("${A:..}")] + public void EnableReferenceResolutionRelativeSegmentNotAtStartThrows(string raw) + { + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Value"] = raw, + }) + .EnableReferenceResolution() + .Build(); + + Assert.Throws(() => _ = config["Value"]); + } + + [Fact] + public void ConfigureReferenceResolutionOnEmptyBuilderThrows() + { + var builder = new ConfigurationBuilder(); + + Assert.Throws(() => builder.ConfigureReferenceResolution(ReferenceMode.Ignore)); + } + + [Fact] + public void ConfigureReferenceResolutionIgnoreHidesValuesFromSubstitution() + { + var hiddenSource = new MemoryConfigurationSource + { + InitialData = new Dictionary { ["X"] = "from-hidden" } + }; + + var config = new ConfigurationBuilder() + .Add(hiddenSource) + .ConfigureReferenceResolution(ReferenceMode.Ignore) + .AddInMemoryCollection(new Dictionary + { + ["K"] = "${X}", + }) + .EnableReferenceResolution() + .Build(); + + Assert.Equal("${X}", config["K"]); + Assert.Equal("from-hidden", config["X"]); + } + + [Fact] + public void ConfigureReferenceResolutionBySourceNoneHidesValuesFromSubstitution() + { + var hiddenSource = new MemoryConfigurationSource + { + InitialData = new Dictionary { ["X"] = "from-hidden" } + }; + var builder = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["K"] = "${X}", + }) + .EnableReferenceResolution(); + + builder.Add(hiddenSource); + builder.ConfigureReferenceResolution(hiddenSource, ReferenceMode.Ignore); + + IConfigurationRoot config = builder.Build(); + + Assert.Equal("${X}", config["K"]); + Assert.Equal("from-hidden", config["X"]); + } + + [Fact] + public void ConfigureReferenceResolutionBySourceNotPresentThrows() + { + var builder = new ConfigurationBuilder().AddInMemoryCollection(); + var otherSource = new MemoryConfigurationSource(); + + Assert.Throws(() => builder.ConfigureReferenceResolution(otherSource, ReferenceMode.Ignore)); + } + + [Fact] + public void ConfigureReferenceResolutionIgnoreChildrenStillVisibleInGetChildren() + { + var hiddenSource = new MemoryConfigurationSource + { + InitialData = new Dictionary + { + ["Section:A"] = "a", + ["Section:B"] = "b", + } + }; + + var config = new ConfigurationBuilder() + .Add(hiddenSource) + .ConfigureReferenceResolution(ReferenceMode.Ignore) + .EnableReferenceResolution() + .Build(); + + IEnumerable childKeys = config.GetSection("Section").GetChildren().Select(c => c.Key).OrderBy(k => k); + Assert.Equal(new[] { "A", "B" }, childKeys); + } + + [Fact] + public void ConfigureReferenceResolutionReadTreatsOwnValuesAsLiteralButServesAsTarget() + { + // Source in Read-only mode is a substitution target but its own ${...} stays literal. + var readOnlySource = new MemoryConfigurationSource + { + InitialData = new Dictionary + { + ["X"] = "from-readonly", + ["Self"] = "${X}", + } + }; + + var config = new ConfigurationBuilder() + .Add(readOnlySource) + .ConfigureReferenceResolution(ReferenceMode.Read) + .AddInMemoryCollection(new Dictionary + { + ["K"] = "${X}", + }) + .EnableReferenceResolution() + .Build(); + + Assert.Equal("from-readonly", config["K"]); + Assert.Equal("${X}", config["Self"]); + } + + private sealed class StubBuilder : IConfigurationBuilder + { + public IDictionary Properties { get; } = new Dictionary(); + public IList Sources { get; } = new List(); + public IConfigurationBuilder Add(IConfigurationSource source) { Sources.Add(source); return this; } + public IConfigurationRoot Build() => throw new NotImplementedException(); + } + [Fact] public void SameReloadTokenIsReturnedRepeatedly() { diff --git a/src/libraries/Microsoft.Extensions.Configuration/tests/FunctionalTests/ConfigurationTests.cs b/src/libraries/Microsoft.Extensions.Configuration/tests/FunctionalTests/ConfigurationTests.cs index f6bbb5adc056e5..2bf864eed3eef1 100644 --- a/src/libraries/Microsoft.Extensions.Configuration/tests/FunctionalTests/ConfigurationTests.cs +++ b/src/libraries/Microsoft.Extensions.Configuration/tests/FunctionalTests/ConfigurationTests.cs @@ -1018,6 +1018,80 @@ private sealed class MyOptions public string XmlKey1 { get; set; } } + [Fact] + public void ReferenceResolution_RealWorldScenario_CombinesSectionAliasOverridesAndTokenSyntax() + { + // appsettings.json provides defaults, including a Services template ("Defaults:Service") that + // will be reused via a section alias. + _fileSystem.WriteFile(_jsonFile, @"{ + ""Environment"": ""Staging"", + ""Defaults"": { + ""Service"": { + ""Host"": ""primary.example.com"", + ""Port"": ""5432"", + ""Protocol"": ""tcp"" + } + }, + ""Services"": { + ""Primary"": ""${Defaults:Service}"" + }, + ""ConnectionString"": ""${Services:Primary:Protocol}://${User}@${Services:Primary:Host}:${Services:Primary:Port}/${Database?Defaults:Database}"", + ""Tracing"": { + ""Collector"": ""${Tracing:Endpoint?}"" + }, + ""Defaults"": { + ""Database"": ""appdb"" + }, + ""Metadata"": { + ""Literal"": ""${do-not-interpret}"" + } +}"); + + // INI layered on top of the JSON. It supplies the User secret and, critically, overrides a + // single child of the aliased section (Services:Primary:Port) at a LATER provider than the + // alias itself. This validates that provider-order overrides still win over alias projection. + _fileSystem.WriteFile(_iniFile, @"User = admin +[Services:Primary] +Port = 6543"); + + var config = CreateBuilder() + .AddJsonFile(_jsonFile, optional: false, reloadOnChange: false) + .AddIniFile(_iniFile, optional: false, reloadOnChange: false) + .AddInMemoryCollection(new Dictionary + { + // Exercises ${Key?Fallback} — the key isn't present so the fallback ref ("Defaults:Database") applies. + // We intentionally don't set "Database" anywhere else in the chain. + }) + .EnableReferenceResolution() + .Build(); + + // Section alias projects the Defaults:Service children under Services:Primary... + Assert.Equal("primary.example.com", config["Services:Primary:Host"]); + Assert.Equal("tcp", config["Services:Primary:Protocol"]); + + // ...but the INI override (later provider) shadows a single child. + Assert.Equal("6543", config["Services:Primary:Port"]); + + // GetChildren surfaces the union of alias-projected keys and overlaid keys, with no duplicates. + string[] primaryChildren = config.GetSection("Services:Primary") + .GetChildren() + .Select(c => c.Key) + .OrderBy(k => k, StringComparer.OrdinalIgnoreCase) + .ToArray(); + Assert.Equal(new[] { "Host", "Port", "Protocol" }, primaryChildren); + + // Multi-token interpolation composes across JSON (aliased), INI (override + secret), and a + // default-value token — the single ConnectionString pulls from three providers at once. + Assert.Equal("tcp://admin@primary.example.com:6543/appdb", config["ConnectionString"]); + + // Optional ${?Tracing:Endpoint} resolves to an empty string because the key is missing. + Assert.Equal(string.Empty, config["Tracing:Collector"]); + + // Excluded scan path: the literal reference text is returned as-is (not interpreted, + // not treated as a missing-key failure). + Assert.Equal("${do-not-interpret}", config["Metadata:Literal"]); + } + private async Task WatchOverConfigJsonFileAndUpdateIt(string filePath) { var builder = new ConfigurationBuilder().AddJsonFile(filePath, true, true).Build(); diff --git a/src/libraries/Microsoft.Extensions.Configuration/tests/FunctionalTests/Microsoft.Extensions.Configuration.Functional.Tests.csproj b/src/libraries/Microsoft.Extensions.Configuration/tests/FunctionalTests/Microsoft.Extensions.Configuration.Functional.Tests.csproj index d23ce8155b6251..d4ecb9fcddcc69 100644 --- a/src/libraries/Microsoft.Extensions.Configuration/tests/FunctionalTests/Microsoft.Extensions.Configuration.Functional.Tests.csproj +++ b/src/libraries/Microsoft.Extensions.Configuration/tests/FunctionalTests/Microsoft.Extensions.Configuration.Functional.Tests.csproj @@ -7,6 +7,7 @@ + diff --git a/src/libraries/Microsoft.Extensions.Configuration/tests/ReferenceResolutionTestShims.cs b/src/libraries/Microsoft.Extensions.Configuration/tests/ReferenceResolutionTestShims.cs new file mode 100644 index 00000000000000..f78db93fbc4060 --- /dev/null +++ b/src/libraries/Microsoft.Extensions.Configuration/tests/ReferenceResolutionTestShims.cs @@ -0,0 +1,49 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; + +namespace Microsoft.Extensions.Configuration +{ + // Test-only compatibility shim mapping the legacy EnableReferenceResolution / ConfigureReferenceResolution + // names to SetReferenceMode. Keeps the large test suite compiling without per-call rewrites. + internal static class ReferenceResolutionTestShims + { + public static IConfigurationBuilder EnableReferenceResolution(this IConfigurationBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + // Apply Scan only to sources that haven't already been assigned a mode, so earlier + // ConfigureReferenceResolution(mode) / ConfigureReferenceResolution(source, mode) calls + // remain in effect. + const string SourceModesPropertyName = "Microsoft.Extensions.Configuration.ReferenceResolution.SourceModes"; + var existing = builder.Properties.TryGetValue(SourceModesPropertyName, out object? raw) + ? raw as System.Collections.IDictionary + : null; + foreach (IConfigurationSource source in builder.Sources) + { + if (existing is null || !existing.Contains(source)) + { + builder.SetReferenceMode(source, ReferenceMode.Scan); + } + } + return builder; + } + + // Marks every currently-added source as Scan. Tests that use this pair it with the most-recently + // added source; since SetReferenceMode(source, ...) is used where targeting is needed, the + // default overload marks all sources as Scan. + public static IConfigurationBuilder ConfigureReferenceResolution(this IConfigurationBuilder builder, ReferenceMode mode) + { + ArgumentNullException.ThrowIfNull(builder); + if (builder.Sources.Count == 0) + { + throw new InvalidOperationException("No sources."); + } + return builder.SetReferenceMode(builder.Sources[builder.Sources.Count - 1], mode); + } + + public static IConfigurationBuilder ConfigureReferenceResolution(this IConfigurationBuilder builder, IConfigurationSource source, ReferenceMode mode) + => builder.SetReferenceMode(source, mode); + } +} From 1a548125d0746b3eba93fd8da820c3c96bf1e502 Mon Sep 17 00:00:00 2001 From: rosebyte Date: Thu, 23 Apr 2026 06:53:00 +0200 Subject: [PATCH 02/10] point --- .../src/ReferenceMode.cs | 8 +- .../src/ReferenceParser.cs | 155 +++++++++++++----- ...esolutionConfigurationBuilderExtensions.cs | 2 +- .../src/ReferenceResolutionEngine.cs | 16 +- .../src/Resources/Strings.resx | 4 +- .../tests/ConfigurationManagerTest.cs | 16 +- .../tests/ConfigurationTest.cs | 124 +++++++------- .../FunctionalTests/ConfigurationTests.cs | 14 +- 8 files changed, 211 insertions(+), 128 deletions(-) diff --git a/src/libraries/Microsoft.Extensions.Configuration/src/ReferenceMode.cs b/src/libraries/Microsoft.Extensions.Configuration/src/ReferenceMode.cs index dfff359b026a69..0685cbfd717eba 100644 --- a/src/libraries/Microsoft.Extensions.Configuration/src/ReferenceMode.cs +++ b/src/libraries/Microsoft.Extensions.Configuration/src/ReferenceMode.cs @@ -4,7 +4,7 @@ namespace Microsoft.Extensions.Configuration { /// - /// Controls how an participates in ${...} + /// Controls how an participates in ref(...) / fmt(...) /// reference resolution performed by the built from the /// containing . /// @@ -18,7 +18,7 @@ namespace Microsoft.Extensions.Configuration public enum ReferenceMode { /// - /// The source is invisible to the reference-resolution engine: no ${...} + /// The source is invisible to the reference-resolution engine: no ref(...) / fmt(...) /// reference or section alias in another source can reach its values. Direct reads /// via the normal API still return its values. /// @@ -27,13 +27,13 @@ public enum ReferenceMode /// /// The source is a valid substitution target for references in other /// sources. The source's own values are returned verbatim — - /// ${...} sequences are not interpreted. This is the default mode for + /// ref(...) / fmt(...) sequences are not interpreted. This is the default mode for /// sources that have not been configured explicitly. /// Read = 1, /// - /// The source's values are scanned for ${...} reference tokens and section + /// The source's values are scanned for ref(...) / fmt(...) reference tokens and section /// aliases, and the source is exposed as a substitution target for other /// sources. Marking at least one source with this value /// activates the reference-resolution engine. diff --git a/src/libraries/Microsoft.Extensions.Configuration/src/ReferenceParser.cs b/src/libraries/Microsoft.Extensions.Configuration/src/ReferenceParser.cs index 9ab48c72eec463..33201dbc811c43 100644 --- a/src/libraries/Microsoft.Extensions.Configuration/src/ReferenceParser.cs +++ b/src/libraries/Microsoft.Extensions.Configuration/src/ReferenceParser.cs @@ -7,53 +7,117 @@ namespace Microsoft.Extensions.Configuration { + // Value syntax: + // ref() — the whole value is a reference to another key. Eligible as a + // section-alias target when the path names a section. + // fmt(