diff --git a/src/libraries/Microsoft.Extensions.Configuration/src/ConfigurationBuilder.cs b/src/libraries/Microsoft.Extensions.Configuration/src/ConfigurationBuilder.cs index 75b930f60f16eb..ff6fc68dcf063d 100644 --- a/src/libraries/Microsoft.Extensions.Configuration/src/ConfigurationBuilder.cs +++ b/src/libraries/Microsoft.Extensions.Configuration/src/ConfigurationBuilder.cs @@ -44,11 +44,10 @@ public IConfigurationBuilder Add(IConfigurationSource source) /// An with keys and values from the registered providers. public IConfigurationRoot Build() { - var providers = new List(); + var providers = new List(_sources.Count); foreach (IConfigurationSource source in _sources) { - IConfigurationProvider provider = source.Build(this); - providers.Add(provider); + providers.Add(source.Build(this)); } return new ConfigurationRoot(providers); } diff --git a/src/libraries/Microsoft.Extensions.Configuration/src/ConfigurationManager.cs b/src/libraries/Microsoft.Extensions.Configuration/src/ConfigurationManager.cs index b31c4bb0c6392e..9902eec6047942 100644 --- a/src/libraries/Microsoft.Extensions.Configuration/src/ConfigurationManager.cs +++ b/src/libraries/Microsoft.Extensions.Configuration/src/ConfigurationManager.cs @@ -55,12 +55,22 @@ public string? this[string key] get { using ReferenceCountedProviders reference = _providerManager.GetReference(); - return ConfigurationRoot.GetConfiguration(reference.Providers, key); + if (ReferenceEngine.Disabled) + { + return ConfigurationRoot.GetConfiguration(reference.Providers, key); + } + + // Read the engine once and resolve against its own providers. AddProvider swaps this generation's + // provider list and engine in place, so reading the two separately could pair an old list with a newer + // engine (or the reverse) and resolve the key against the wrong providers. + reference.ReferenceEngine!.TryRead(key, out string? value); + return value; } set { using ReferenceCountedProviders reference = _providerManager.GetReference(); ConfigurationRoot.SetConfiguration(reference.Providers, key, value); + _providerManager.ResetReferenceEngine(); } } @@ -116,6 +126,7 @@ void IConfigurationRoot.Reload() private void RaiseChanged() { + _providerManager.ResetReferenceEngine(); var previousToken = Interlocked.Exchange(ref _changeToken, new ConfigurationReloadToken()); previousToken.OnReload(); } diff --git a/src/libraries/Microsoft.Extensions.Configuration/src/ConfigurationProvider.cs b/src/libraries/Microsoft.Extensions.Configuration/src/ConfigurationProvider.cs index 3f39758d25b797..b14d3670635669 100644 --- a/src/libraries/Microsoft.Extensions.Configuration/src/ConfigurationProvider.cs +++ b/src/libraries/Microsoft.Extensions.Configuration/src/ConfigurationProvider.cs @@ -29,6 +29,10 @@ protected ConfigurationProvider() /// protected IDictionary Data { get; set; } + // Exposes this provider's key/value pairs so the reference engine can build its reference index by scanning the + // already-loaded backing dictionary directly, instead of going through the O(n^2) GetChildKeys reconstruction. + internal IEnumerable> GetEntriesForReferenceScan() => Data; + /// /// Attempts to find a value with the given key. /// diff --git a/src/libraries/Microsoft.Extensions.Configuration/src/ConfigurationRoot.cs b/src/libraries/Microsoft.Extensions.Configuration/src/ConfigurationRoot.cs index bd53b747a43822..22b4b279120c3d 100644 --- a/src/libraries/Microsoft.Extensions.Configuration/src/ConfigurationRoot.cs +++ b/src/libraries/Microsoft.Extensions.Configuration/src/ConfigurationRoot.cs @@ -20,6 +20,11 @@ public class ConfigurationRoot : IConfigurationRoot, IDisposable private readonly List _changeTokenRegistrations; private ConfigurationReloadToken _changeToken = new ConfigurationReloadToken(); + // Per-root reference state, bound to the (fixed) provider list and swapped for a fresh instance on every reload + // (see RaiseChanged) so a reload can never surface stale reference information. When no provider declares any + // $ref the index is empty, so reads take the plain provider path with no reference overhead. + private volatile ReferenceEngine? _referenceEngine; + /// /// Initializes a Configuration root with a list of providers. /// @@ -29,6 +34,7 @@ public ConfigurationRoot(IList providers) ArgumentNullException.ThrowIfNull(providers); _providers = providers; + _referenceEngine = ReferenceEngine.Create(providers); _changeTokenRegistrations = new List(providers.Count); foreach (IConfigurationProvider p in providers) { @@ -49,8 +55,21 @@ public ConfigurationRoot(IList providers) /// The configuration value. public string? this[string key] { - get => GetConfiguration(_providers, key); - set => SetConfiguration(_providers, key, value); + get + { + if (ReferenceEngine.Disabled) + { + return GetConfiguration(_providers, key); + } + + _referenceEngine!.TryRead(key, out string? value); + return value; + } + set + { + SetConfiguration(_providers, key, value); + _referenceEngine = ReferenceEngine.Create(_providers); + } } /// @@ -91,10 +110,13 @@ public void Reload() private void RaiseChanged() { + _referenceEngine = ReferenceEngine.Create(_providers); ConfigurationReloadToken previousToken = Interlocked.Exchange(ref _changeToken, new ConfigurationReloadToken()); previousToken.OnReload(); } + internal ReferenceEngine? ReferenceEngine => _referenceEngine; + /// public void Dispose() { diff --git a/src/libraries/Microsoft.Extensions.Configuration/src/InternalConfigurationRootExtensions.cs b/src/libraries/Microsoft.Extensions.Configuration/src/InternalConfigurationRootExtensions.cs index a4a57d45d59930..5bfc5a71b65058 100644 --- a/src/libraries/Microsoft.Extensions.Configuration/src/InternalConfigurationRootExtensions.cs +++ b/src/libraries/Microsoft.Extensions.Configuration/src/InternalConfigurationRootExtensions.cs @@ -20,13 +20,31 @@ internal static class InternalConfigurationRootExtensions /// Immediate children sub-sections of section specified by key. internal static IEnumerable GetChildrenImplementation(this IConfigurationRoot root, string? path) { + // For ConfigurationManager the engine is obtained from the pinned provider generation, so the engine and the + // providers it resolves against are always the same generation. A plain ConfigurationRoot exposes its engine + // directly (its provider list is fixed). using ReferenceCountedProviders? reference = (root as ConfigurationManager)?.GetProvidersReference(); - IEnumerable providers = reference?.Providers ?? root.Providers; + ReferenceEngine? engine = reference?.ReferenceEngine ?? (root as ConfigurationRoot)?.ReferenceEngine; - IEnumerable children = providers - .Aggregate(Enumerable.Empty(), - (seed, source) => source.GetChildKeys(seed, path)) - .Distinct(StringComparer.OrdinalIgnoreCase) + IEnumerable childKeys; + if (ReferenceEngine.Disabled || engine is null || engine.IndexIsEmpty) + { + // No engine (references disabled, or a third-party root), or this generation declares no references: + // enumerate children the plain way, with nothing to resolve, merge or hide, and allocate exactly like a + // reference-free configuration. Use the engine's provider list when present so it matches the generation. + IEnumerable providers = engine?.Providers ?? reference?.Providers ?? root.Providers; + childKeys = providers + .Aggregate(Enumerable.Empty(), (seed, source) => source.GetChildKeys(seed, path)) + .Distinct(StringComparer.OrdinalIgnoreCase); + } + else + { + // References merge into the children: a redirected section lists its target's children unioned with any + // keys a higher provider defines under the reference, at every hop of the chain. + childKeys = engine.ChildKeys(path); + } + + IEnumerable children = childKeys .Select(key => root.GetSection(path == null ? key : path + ConfigurationPath.KeyDelimiter + key)); if (reference is null) @@ -42,19 +60,37 @@ internal static IEnumerable GetChildrenImplementation(thi internal static bool TryGetConfiguration(this IConfigurationRoot root, string key, out string? value) { - // common cases Providers is IList in ConfigurationRoot + // For ConfigurationManager, take a counted reference and use the engine from that pinned generation + // so a concurrent source mutation can neither dispose the providers mid-read nor pair them with a different + // generation's index. The resolved key is then read like any other. + if (!ReferenceEngine.Disabled) + { + if (root is ConfigurationManager cm) + { + using ReferenceCountedProviders reference = cm.GetProvidersReference(); + return reference.ReferenceEngine!.TryRead(key, out value); + } + else if (root is ConfigurationRoot cr) + { + return cr.ReferenceEngine!.TryRead(key, out value); + } + } + + // Plain path: references globally disabled, or a third-party root implementation. + // Commonly Providers is already IList in ConfigurationRoot. IList providers = root.Providers is IList list ? list : root.Providers.ToList(); + return TryGet(providers, key, out value); + } - // ensure looping in the reverse order + private static bool TryGet(IList providers, string key, out string? value) + { for (int i = providers.Count - 1; i >= 0; i--) { - IConfigurationProvider provider = providers[i]; - try { - if (provider.TryGet(key, out value)) + if (providers[i].TryGet(key, out value)) { return true; } @@ -76,6 +112,5 @@ internal static bool TryGetConfiguration(this IConfigurationRoot root, string ke value = null; return false; } - } } diff --git a/src/libraries/Microsoft.Extensions.Configuration/src/Microsoft.Extensions.Configuration.csproj b/src/libraries/Microsoft.Extensions.Configuration/src/Microsoft.Extensions.Configuration.csproj index cb2fe733e9cb41..58461d782758fd 100644 --- a/src/libraries/Microsoft.Extensions.Configuration/src/Microsoft.Extensions.Configuration.csproj +++ b/src/libraries/Microsoft.Extensions.Configuration/src/Microsoft.Extensions.Configuration.csproj @@ -14,6 +14,11 @@ Link="Common\src\Extensions\EmptyDisposable.cs" /> + + + + diff --git a/src/libraries/Microsoft.Extensions.Configuration/src/ReferenceCountedProviders.cs b/src/libraries/Microsoft.Extensions.Configuration/src/ReferenceCountedProviders.cs index 2c2e32fc7e47d0..b94863d0cdd7e1 100644 --- a/src/libraries/Microsoft.Extensions.Configuration/src/ReferenceCountedProviders.cs +++ b/src/libraries/Microsoft.Extensions.Configuration/src/ReferenceCountedProviders.cs @@ -29,16 +29,22 @@ internal abstract class ReferenceCountedProviders : IDisposable // This is Dispose() rather than RemoveReference() so we can conveniently release a reference at the end of a using block. public abstract void Dispose(); + public abstract ReferenceEngine? ReferenceEngine { get; } + + public abstract void ResetReferenceEngine(); + private sealed class ActiveReferenceCountedProviders : ReferenceCountedProviders { private long _refCount = 1; // volatile is not strictly necessary because the runtime adds a barrier either way, but volatile indicates that this field has // unsynchronized readers meaning the all writes initializing the list must be published before updating the _providers reference. private volatile List _providers; + private volatile ReferenceEngine? _referenceEngine; public ActiveReferenceCountedProviders(List providers) { _providers = providers; + _referenceEngine = ReferenceEngine.Create(providers); } public override List Providers @@ -52,11 +58,16 @@ public override List Providers { Debug.Assert(_refCount > 0); _providers = value; + _referenceEngine = ReferenceEngine.Create(value); } } public override List NonReferenceCountedProviders => _providers; + public override ReferenceEngine? ReferenceEngine => _referenceEngine; + + public override void ResetReferenceEngine() => _referenceEngine = ReferenceEngine.Create(_providers); + public override void AddReference() { // AddReference() is always called with a lock to ensure _refCount hasn't already decremented to zero. @@ -78,14 +89,21 @@ public override void Dispose() private sealed class DisposedReferenceCountedProviders : ReferenceCountedProviders { + private volatile ReferenceEngine? _referenceEngine; + public DisposedReferenceCountedProviders(List providers) { Providers = providers; + _referenceEngine = ReferenceEngine.Create(providers); } public override List Providers { get; set; } public override List NonReferenceCountedProviders => Providers; + public override ReferenceEngine? ReferenceEngine => _referenceEngine; + + public override void ResetReferenceEngine() => _referenceEngine = ReferenceEngine.Create(Providers); + public override void AddReference() { } public override void Dispose() { } } diff --git a/src/libraries/Microsoft.Extensions.Configuration/src/ReferenceCountedProvidersManager.cs b/src/libraries/Microsoft.Extensions.Configuration/src/ReferenceCountedProvidersManager.cs index 210e41f665a47a..6faf9732c71c34 100644 --- a/src/libraries/Microsoft.Extensions.Configuration/src/ReferenceCountedProvidersManager.cs +++ b/src/libraries/Microsoft.Extensions.Configuration/src/ReferenceCountedProvidersManager.cs @@ -75,6 +75,17 @@ public void AddProvider(IConfigurationProvider provider) } } + public void ResetReferenceEngine() + { + lock (_replaceProvidersLock) + { + if (!_disposed) + { + _refCountedProviders.ResetReferenceEngine(); + } + } + } + public void Dispose() { ReferenceCountedProviders oldRefCountedProviders = _refCountedProviders; diff --git a/src/libraries/Microsoft.Extensions.Configuration/src/ReferenceEngine.cs b/src/libraries/Microsoft.Extensions.Configuration/src/ReferenceEngine.cs new file mode 100644 index 00000000000000..110b121b5b991b --- /dev/null +++ b/src/libraries/Microsoft.Extensions.Configuration/src/ReferenceEngine.cs @@ -0,0 +1,214 @@ +// 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.Diagnostics.CodeAnalysis; +using System.Linq; + +namespace Microsoft.Extensions.Configuration +{ + // The reference engine for one provider generation: it lazily builds and memoizes a ReferenceIndex from the + // generation's providers, then resolves keys against it. Bound to the exact provider list it serves and swapped for + // a fresh instance whenever that list or the providers' data change (a new ReferenceCountedProviders generation, or + // a ConfigurationRoot reload), so resolution always runs against the very list the index was built from - the two + // can never be paired with a different (for example, newer) provider list, and a change can never surface stale + // reference information. + internal sealed class ReferenceEngine + { + // A process-wide opt-out for apps that do not want configuration references at all. The value is a feature + // switch so the resolution paths can be trimmed away when set. Read as the outermost gate at every call site, so + // a set switch lets the trimmer drop the whole reference path. + [FeatureSwitchDefinition("Microsoft.Extensions.Configuration.DisableConfigurationReferences")] + internal static bool Disabled { get; } = + AppContext.TryGetSwitch("Microsoft.Extensions.Configuration.DisableConfigurationReferences", out bool disabled) && disabled; + + private readonly object _lock = new object(); + + // Volatile so the lock-free fast-path read publishes the fully-built index (release on assignment, acquire on + // read) on weak memory models. Null means "not computed yet"; ReferenceIndex.Empty means "computed, nothing to + // resolve". + private volatile ReferenceIndex? _index; + + private ReferenceEngine(IList providers) => Providers = providers; + + public static ReferenceEngine? Create(IList providers) => Disabled ? null : new ReferenceEngine(providers); + + public IList Providers { get; } + + // The memoized reference index for this generation, built once from Providers. ReferenceIndex.Build returns the + // empty sentinel when no provider declares any $ref, so that "nothing to resolve" case lands on IsEmpty. + private ReferenceIndex Index() + { + ReferenceIndex? computed = _index; + if (computed is null) + { + lock (_lock) + { + computed = _index ??= ReferenceIndex.Build(Providers); + } + } + + return computed; + } + + // Whether this generation resolves nothing (no provider declares a $ref). Lets enumeration take the plain + // provider path - allocating like a reference-free configuration - instead of the merge path. The index is + // built (once, memoized) to answer this, which the first read of the generation does anyway. + internal bool IndexIsEmpty => Index().IsEmpty; + + // Reads , applying recursive-merge reference resolution. The key is followed hop by hop + // through the reference chain; at each hop a provider strictly above the reference's declaring level may + // override the mirrored value at that exact path, otherwise the mirror is followed. When no reference governs + // the running key it is read across all providers. Returns whether a value was found. + public bool TryRead(string key, out string? value) + { + ReferenceIndex index = Index(); + // A $ref key is structural metadata, not data: read it raw without resolving, so it neither redirects + // through its own parent's reference nor joins the walk. An empty index likewise means nothing resolves. + if (index.IsEmpty || ReferenceIndex.IsRefKey(key, out _)) + { + // This generation holds no references (or the key is a marker), so read the key directly. + return TryGetAbove(key, minLevelExclusive: -1, out value); + } + + string current = key; + CycleGuard guard = default; + while (index.TryGetGoverningRef(current, out string? target, out int level, out int prefixLength)) + { + // A higher-precedence provider (strictly above the one that declared the reference) can override the + // mirrored value at this exact path; that override wins over following the reference. + if (TryGetAbove(current, level, out value)) + { + return true; + } + + guard.Advance(current, prefixLength); + current = prefixLength == current.Length + ? target + : target + current.Substring(prefixLength); + } + + return TryGetAbove(current, minLevelExclusive: -1, out value); + } + + // Collects the immediate child key segments of under recursive-merge resolution: the + // union, deduplicated, of the override children each higher provider defines at every reference hop and the + // mirror children of the final target. + public List ChildKeys(string? path) + { + ReferenceIndex index = Index(); + var result = new List(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + + string? current = path; + bool merged = false; + if (!index.IsEmpty) + { + // Only walk the reference chain when there is something to resolve; otherwise fall straight through to + // the plain enumeration of the path below. + CycleGuard guard = default; + while (current is not null && index.TryGetGoverningRef(current, out string? target, out int level, out int prefixLength)) + { + AddChildKeysAbove(result, seen, current, level); + guard.Advance(current, prefixLength); + current = prefixLength == current.Length + ? target + : target + current.Substring(prefixLength); + merged = true; + } + } + + AddChildKeysAbove(result, seen, current, minLevelExclusive: -1); + + if (merged) + { + // A resolved section unions children drawn from several providers across the reference hops; each block + // arrives sorted on its own but the concatenation is not, so restore the ConfigurationKeyComparer order + // GetChildren guarantees. The plain (no-hop) path already comes out ordered from the providers, so it + // is left untouched. + result.Sort(ConfigurationKeyComparer.Comparison); + } + + return result; + } + + // Reads from the providers strictly above , highest + // precedence first, tolerating a provider disposed concurrently (as the plain read path does). + private bool TryGetAbove(string key, int minLevelExclusive, out string? value) + { + for (int i = Providers.Count - 1; i > minLevelExclusive; i--) + { + try + { + if (Providers[i].TryGet(key, out value)) + { + return true; + } + } + catch (ObjectDisposedException) + { + } + } + + value = null; + return false; + } + + // Adds the immediate child segments of drawn from the providers strictly above + // , skipping segments already collected and tolerating a disposed provider. + private void AddChildKeysAbove(List result, HashSet seen, string? path, int minLevelExclusive) + { + IEnumerable keys = Enumerable.Empty(); + for (int i = minLevelExclusive + 1; i < Providers.Count; i++) + { + try + { + keys = Providers[i].GetChildKeys(keys, path); + } + catch (ObjectDisposedException) + { + } + } + + foreach (string key in keys) + { + // Hide the reserved $ref marker: it declares the reference, it is not a child of the section. + if (!ReferenceIndex.IsRefSegment(key) && seen.Add(key)) + { + result.Add(key); + } + } + } + + // Guards a single reference-resolution walk against a chain that never terminates. Every hop redirects through + // a governing reference key drawn from a finite set, so a walk that does not terminate - whether it repeats + // (A -> B -> A) or grows without bound (A -> A:B) - must fire some governing key twice, while a terminating + // walk fires each at most once. The first GraceHops hops are not recorded, so a chain that resolves quickly + // (the common case) allocates nothing; the fired-key set is created only once a walk outlives the grace period. + private struct CycleGuard + { + // A reference chain resolves in as many hops as it has links, a handful for any real configuration, so only + // a cycle or a pathologically deep chain outlasts this. + private const int GraceHops = 32; + + private int _hops; + private HashSet? _fired; + + public void Advance(string key, int prefixLength) + { + if (++_hops <= GraceHops) + { + return; + } + + string governing = key.Substring(0, prefixLength); + if (!(_fired ??= new HashSet(StringComparer.OrdinalIgnoreCase)).Add(governing)) + { + throw new InvalidOperationException( + SR.Format(SR.Error_ReferenceCycle, string.Join(" -> ", _fired) + " -> " + governing)); + } + } + } + } +} diff --git a/src/libraries/Microsoft.Extensions.Configuration/src/ReferenceIndex.cs b/src/libraries/Microsoft.Extensions.Configuration/src/ReferenceIndex.cs new file mode 100644 index 00000000000000..6ce275e730f1b6 --- /dev/null +++ b/src/libraries/Microsoft.Extensions.Configuration/src/ReferenceIndex.cs @@ -0,0 +1,193 @@ +// 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.Diagnostics.CodeAnalysis; +using System.Linq; + +namespace Microsoft.Extensions.Configuration +{ + // A snapshot of every configuration path that is a reference, mapping the path to its immediate target. Built once + // per provider generation by scanning the providers (see Build). A reference is declared by a reserved "$ref" child + // key - ":$ref = " - so a plain value is never mistaken for a reference and no escaping is needed; the + // highest-precedence provider that declares a path's $ref wins, and an empty target drops it. Entries are kept + // unflattened - each maps to its immediate target - so resolution follows the chain hop by hop and can apply a + // higher provider's override at every hop; a chain that never terminates (a cycle, or a self-reference that grows + // without bound) is caught by the resolution walk itself (see ReferenceEngine.CycleGuard). + internal sealed class ReferenceIndex + { + public static readonly ReferenceIndex Empty = new ReferenceIndex(new Dictionary(StringComparer.OrdinalIgnoreCase)); + + // The reserved final segment that declares a reference: ":$ref = " makes a reference to + // . Because the marker is a key, not a value, values are always literal and never need escaping. + internal const string RefSegment = "$ref"; + + // Each reference key maps to its target and the level (provider index) of the provider that declared it. The + // level lets resolution honour a higher-precedence provider that overrides a mirrored value. Entries are kept + // unflattened: resolution follows the chain hop by hop so it can apply overrides at every hop. + private readonly Dictionary _targets; +#if NET9_0_OR_GREATER + private readonly Dictionary.AlternateLookup> _bySpan; +#endif + + public ReferenceIndex(Dictionary targets) + { + _targets = targets; +#if NET9_0_OR_GREATER + _bySpan = targets.GetAlternateLookup>(); +#endif + } + + public bool IsEmpty => _targets.Count == 0; + + // Finds the prefix of - including the key itself - that governs it, returning its + // , the (provider index) of the provider that declared it, + // and the length of the matched prefix. Among the prefixes that are references, the one declared by the + // highest-precedence provider governs; ties (references declared at the same level) are broken by the + // shallowest (outermost) prefix, so a nested reference under the same provider's alias is ignored while a more + // specific reference declared by a higher provider still wins over a lower provider's ancestor reference. + // Returns false when no prefix is a reference. Resolution walks these hop by hop (see ReferenceEngine.TryRead), + // applying a higher provider's override at each hop. + internal bool TryGetGoverningRef(string key, [NotNullWhen(true)] out string? target, out int level, out int prefixLength) + { + target = null; + level = -1; + prefixLength = 0; + + if (_targets.Count != 0) + { + int start = 0; + while (true) + { + int delimiter = key.IndexOf(ConfigurationPath.KeyDelimiter[0], start); + int end = delimiter < 0 ? key.Length : delimiter; +#if NET9_0_OR_GREATER + if (_bySpan.TryGetValue(key.AsSpan(0, end), out (string Target, int Level) entry) && entry.Level > level) +#else + if (_targets.TryGetValue(key.Substring(0, end), out (string Target, int Level) entry) && entry.Level > level) +#endif + { + // Only a strictly higher level replaces the running best, so on a tie the first (shallowest) + // match is kept. + target = entry.Target; + level = entry.Level; + prefixLength = end; + } + + if (delimiter < 0) + { + break; + } + start = delimiter + 1; + } + } + + return target is not null; + } + + // Builds the index for a provider generation. Scans every provider (a ChainedConfigurationProvider resolves its + // own references and hides its inner providers, so it is skipped) from highest to lowest precedence for "$ref" + // keys. Each ":$ref = " records -> (, declaring level); the highest provider that + // declares a path's $ref wins (a lower provider's $ref for the same path is ignored) and an empty target drops + // the reference. Entries are kept unflattened so resolution can apply a higher provider's override at each hop + // of a chain; a chain that never terminates is caught later, by the resolution walk (see ReferenceEngine). + public static ReferenceIndex Build(IList providers) + { + Dictionary? targets = null; + HashSet? claimed = null; + + for (int i = providers.Count - 1; i >= 0; i--) + { + IConfigurationProvider provider = providers[i]; + if (provider is ChainedConfigurationProvider) + { + // A ChainedConfigurationProvider wraps a whole IConfiguration that already resolves its own + // references, and its merged view hides the inner providers. Its values are still read through the + // normal provider path, but it contributes none of its own references to this index. + continue; + } + + foreach (KeyValuePair entry in ScanProvider(provider)) + { + // The highest provider that declares a path's $ref decides it; claim the path here so a lower + // provider's $ref for the same path is ignored. An empty target claims the path but records no + // reference, so a higher provider can drop a lower reference by setting its $ref to empty. + if (IsRefKey(entry.Key, out string? referencePath) + && (claimed ??= new HashSet(StringComparer.OrdinalIgnoreCase)).Add(referencePath) + && !string.IsNullOrEmpty(entry.Value)) + { + (targets ??= new Dictionary(StringComparer.OrdinalIgnoreCase))[referencePath] = (entry.Value!, i); + } + } + } + + return targets is null ? Empty : new ReferenceIndex(targets); + } + + // The key/value pairs of a provider for the reference scan: a ConfigurationProvider exposes its loaded + // dictionary directly; any other provider is enumerated through GetChildKeys. + private static IEnumerable> ScanProvider(IConfigurationProvider provider) + => provider is ConfigurationProvider concrete ? concrete.GetEntriesForReferenceScan() : EnumerateProvider(provider); + + // Walks every key of a non-scannable provider via GetChildKeys, collecting those that hold a value. A provider + // disposed mid-walk (concurrently, or after the ConfigurationManager itself was disposed) contributes whatever + // was collected before it threw, matching the plain read path that skips a provider throwing + // ObjectDisposedException. Collected eagerly because Build consumes the whole enumeration anyway. + private static List> EnumerateProvider(IConfigurationProvider provider) + { + var entries = new List>(); + var pending = new Stack(); + pending.Push(null); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + + try + { + while (pending.Count > 0) + { + string? parent = pending.Pop(); + foreach (string child in provider.GetChildKeys(Enumerable.Empty(), parent)) + { + string childKey = parent is null ? child : parent + ConfigurationPath.KeyDelimiter + child; + if (!seen.Add(childKey)) + { + continue; + } + + if (provider.TryGet(childKey, out string? value)) + { + entries.Add(new KeyValuePair(childKey, value)); + } + pending.Push(childKey); + } + } + } + catch (ObjectDisposedException) + { + } + + return entries; + } + + // Whether declares a reference - its final segment is the reserved "$ref" - and, if so, + // the it governs (the key without the trailing ":$ref"). A bare "$ref" at the + // root is not a reference, as there is no path for it to govern. + internal static bool IsRefKey(string key, [NotNullWhen(true)] out string? referencePath) + { + int suffixLength = ConfigurationPath.KeyDelimiter.Length + RefSegment.Length; + if (key.Length > suffixLength + && key[key.Length - suffixLength] == ConfigurationPath.KeyDelimiter[0] + && string.Compare(key, key.Length - RefSegment.Length, RefSegment, 0, RefSegment.Length, StringComparison.OrdinalIgnoreCase) == 0) + { + referencePath = key.Substring(0, key.Length - suffixLength); + return true; + } + + referencePath = null; + return false; + } + + // Whether a single key segment is the reserved "$ref" marker, so enumeration can hide it. + internal static bool IsRefSegment(string segment) => string.Equals(segment, RefSegment, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/src/libraries/Microsoft.Extensions.Configuration/src/Resources/Strings.resx b/src/libraries/Microsoft.Extensions.Configuration/src/Resources/Strings.resx index 4a1f5628be6829..047e7936737532 100644 --- a/src/libraries/Microsoft.Extensions.Configuration/src/Resources/Strings.resx +++ b/src/libraries/Microsoft.Extensions.Configuration/src/Resources/Strings.resx @@ -129,4 +129,7 @@ Source.Stream cannot be null. + + A configuration reference cycle was detected: {0}. + \ No newline at end of file diff --git a/src/libraries/Microsoft.Extensions.Configuration/tests/ConfigurationManagerTest.cs b/src/libraries/Microsoft.Extensions.Configuration/tests/ConfigurationManagerTest.cs index e84f51b2c457fb..65ec48a11df6d2 100644 --- a/src/libraries/Microsoft.Extensions.Configuration/tests/ConfigurationManagerTest.cs +++ b/src/libraries/Microsoft.Extensions.Configuration/tests/ConfigurationManagerTest.cs @@ -1290,14 +1290,16 @@ public BlockReadOnMREProvider(ManualResetEventSlim mre, TimeSpan timeout) public override bool TryGet(string key, out string? value) { - _readStartedTcs.SetResult(null); + // A single configuration read may probe the provider more than once (for example when references are + // resolved), so the "read started" latch must be idempotent. + _readStartedTcs.TrySetResult(null); Assert.True(_mre.Wait(_timeout), "BlockReadOnMREProvider.TryGet() timed out."); return base.TryGet(key, out value); } public override IEnumerable GetChildKeys(IEnumerable earlierKeys, string? parentPath) { - _readStartedTcs.SetResult(null); + _readStartedTcs.TrySetResult(null); Assert.True(_mre.Wait(_timeout), "BlockReadOnMREProvider.GetChildKeys() timed out."); return base.GetChildKeys(earlierKeys, parentPath); } diff --git a/src/libraries/Microsoft.Extensions.Configuration/tests/ConfigurationReferenceTests.cs b/src/libraries/Microsoft.Extensions.Configuration/tests/ConfigurationReferenceTests.cs new file mode 100644 index 00000000000000..b7b23c18108584 --- /dev/null +++ b/src/libraries/Microsoft.Extensions.Configuration/tests/ConfigurationReferenceTests.cs @@ -0,0 +1,1089 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using Microsoft.DotNet.RemoteExecutor; +using Microsoft.Extensions.Configuration.Memory; +using Microsoft.Extensions.Primitives; +using Xunit; + +namespace Microsoft.Extensions.Configuration.Test +{ + public class ConfigurationReferenceTests + { + public enum RootKind + { + Builder, + Manager, + } + + public static IEnumerable RootKinds() => new[] + { + new object[] { RootKind.Builder }, + new object[] { RootKind.Manager }, + }; + + // === Opt-in === + + [Theory] + [MemberData(nameof(RootKinds))] + public void OptedInProvider_ResolvesReference(RootKind kind) + { + IConfigurationRoot root = BuildRoot(kind, Enabled( + ("Shared:Credential", "secret"), + ("Client:Credential", "ref(Shared:Credential)"))); + + Assert.Equal("secret", root["Client:Credential"]); + } + + [Theory] + [MemberData(nameof(RootKinds))] + public void RefShapedValue_IsAlwaysLiteral(RootKind kind) + { + // References are declared with a $ref key, never a value, so a value that merely looks like ref(...) is a + // plain literal and is never resolved. + IConfigurationRoot root = BuildRoot(kind, Plain( + ("Shared:Credential", "secret"), + ("Client:Credential", "ref(Shared:Credential)"))); + + Assert.Equal("ref(Shared:Credential)", root["Client:Credential"]); + Assert.Null(root["Client:Credential:Anything"]); + } + + [ConditionalFact(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))] + [SkipOnTargetFramework(TargetFrameworkMonikers.NetFramework)] // RuntimeConfigurationOptions are not supported on .NET Framework. + public void GloballyDisabled_ReferenceNotResolved() + { + var options = new RemoteInvokeOptions(); + options.RuntimeConfigurationOptions.Add("Microsoft.Extensions.Configuration.DisableConfigurationReferences", bool.TrueString); + + using RemoteInvokeHandle handle = RemoteExecutor.Invoke(static () => + { + IConfigurationRoot root = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Shared:Credential"] = "secret", + ["Client:Credential:$ref"] = "Shared:Credential", + }) + .Build(); + + // With references globally disabled, the $ref marker is inert data: the reference is not resolved + // (Client:Credential has no value of its own) and the marker key reads back verbatim. + Assert.Null(root["Client:Credential"]); + Assert.Equal("Shared:Credential", root["Client:Credential:$ref"]); + }, options); + } + + // === Basic resolution === + + [Theory] + [MemberData(nameof(RootKinds))] + public void PlainValue_IsUnchanged(RootKind kind) + { + IConfigurationRoot root = BuildRoot(kind, Enabled(("Client:Credential", "literal"))); + + Assert.Equal("literal", root["Client:Credential"]); + } + + [Theory] + [MemberData(nameof(RootKinds))] + public void MissingTarget_ResolvesToNull(RootKind kind) + { + IConfigurationRoot root = BuildRoot(kind, Enabled(("Client:Credential", "ref(Missing)"))); + + Assert.Null(root["Client:Credential"]); + } + + [Fact] + public void RefMarker_IsHiddenFromEnumeration() + { + // The $ref marker declares the reference; it must not surface as a child of the reference section (else it + // would leak into GetChildren, AsEnumerable and the configuration binder). A local override alongside it is + // still surfaced. + IConfigurationRoot root = BuildLayered( + Enabled(("Client:Credential", "ref(Shared:Credential)"), ("Shared:Credential:Id", "id")), + Plain(("Client:Credential:Extra", "local"))); + + string[] children = root.GetSection("Client:Credential").GetChildren().Select(c => c.Key).OrderBy(k => k).ToArray(); + Assert.Equal(new[] { "Extra", "Id" }, children); + Assert.DoesNotContain(children, c => string.Equals(c, "$ref", System.StringComparison.OrdinalIgnoreCase)); + } + + [Theory] + [MemberData(nameof(RootKinds))] + public void Chain_ResolvesAcrossHops(RootKind kind) + { + IConfigurationRoot root = BuildRoot(kind, Enabled( + ("A", "ref(B)"), + ("B", "ref(C)"), + ("C", "value"))); + + Assert.Equal("value", root["A"]); + } + + [Theory] + [MemberData(nameof(RootKinds))] + public void Reference_AliasIntoReferencedSubtree_ResolvesAcrossEntries(RootKind kind) + { + // A aliases its whole subtree to X, and X:B is itself a reference to Y, so reading A:B hops twice: + // A:B -> X:B -> Y. This cross-entry chain cannot be pre-flattened (A: is not an index key), so + // resolution must iterate; a single redirect would stop at the literal "ref(Y)". + IConfigurationRoot root = BuildRoot(kind, Enabled( + ("A", "ref(X)"), + ("X:B", "ref(Y)"), + ("Y", "value"))); + + Assert.Equal("value", root["A:B"]); + } + + [Theory] + [MemberData(nameof(RootKinds))] + public void Reference_IsCaseInsensitive(RootKind kind) + { + IConfigurationRoot root = BuildRoot(kind, Enabled( + ("Shared:Credential", "secret"), + ("Client:Credential", "ref(SHARED:credential)"))); + + Assert.Equal("secret", root["client:CREDENTIAL"]); + } + + [Theory] + [MemberData(nameof(RootKinds))] + public void Reference_BodyIsTrimmed(RootKind kind) + { + // Whitespace around the target inside ref(...) is trimmed. + IConfigurationRoot root = BuildRoot(kind, Enabled( + ("Shared", "secret"), + ("Client", "ref( Shared )"))); + + Assert.Equal("secret", root["Client"]); + } + + // === Subtree mirroring === + + [Theory] + [MemberData(nameof(RootKinds))] + public void Subtree_IsMirroredThroughSuffix(RootKind kind) + { + IConfigurationRoot root = BuildRoot(kind, Enabled( + ("Shared:Credential:ClientId", "id-123"), + ("Client:Credential", "ref(Shared:Credential)"))); + + Assert.Equal("id-123", root["Client:Credential:ClientId"]); + } + + [Theory] + [MemberData(nameof(RootKinds))] + public void Subtree_MirrorsGrandchildren(RootKind kind) + { + IConfigurationRoot root = BuildRoot(kind, Enabled( + ("Shared:Credential:Nested:Leaf", "deep"), + ("Client:Credential", "ref(Shared:Credential)"))); + + Assert.Equal("deep", root["Client:Credential:Nested:Leaf"]); + } + + [Theory] + [MemberData(nameof(RootKinds))] + public void Subtree_GetChildren_ListsTargetChildren_IgnoringDescendantKeys(RootKind kind) + { + // Client:Credential aliases to Shared:Credential, so its children are the target's children only; a key + // defined under the reference (Client:Credential:Extra) is inside the alias and is not surfaced. + IConfigurationRoot root = BuildRoot(kind, Enabled( + ("Shared:Credential:ClientId", "id"), + ("Client:Credential", "ref(Shared:Credential)"), + ("Client:Credential:Extra", "own"))); + + string[] children = root.GetSection("Client:Credential").GetChildren().Select(c => c.Key).OrderBy(k => k).ToArray(); + Assert.Equal(new[] { "ClientId" }, children); + } + + [Theory] + [MemberData(nameof(RootKinds))] + public void Reference_ToSectionWithoutScalar_HasNullValueButMirrorsChildren(RootKind kind) + { + IConfigurationRoot root = BuildRoot(kind, Enabled( + ("Shared:Credential:ClientId", "id"), + ("Client:Credential", "ref(Shared:Credential)"))); + + Assert.Null(root["Client:Credential"]); + Assert.Equal(new[] { "ClientId" }, root.GetSection("Client:Credential").GetChildren().Select(c => c.Key).ToArray()); + } + + [Theory] + [MemberData(nameof(RootKinds))] + public void TryGetValue_ThroughReference_BehavesLikeTheTarget(RootKind kind) + { + // A reference is read like any other key once resolved, so TryGetValue reports the target's presence: a + // reference to a value is found; a reference to a valueless section or to a missing key is not found, exactly + // as reading the target directly would be. + IConfigurationRoot root = BuildRoot(kind, Enabled( + ("Shared:Scalar", "v"), + ("Shared:Section:Child", "c"), + ("RefToValue", "ref(Shared:Scalar)"), + ("RefToSection", "ref(Shared:Section)"), + ("RefToMissing", "ref(Nowhere)"))); + + Assert.True(((ConfigurationSection)root.GetSection("RefToValue")).TryGetValue(null, out string? scalar)); + Assert.Equal("v", scalar); + + Assert.False(((ConfigurationSection)root.GetSection("RefToSection")).TryGetValue(null, out string? section)); + Assert.Null(section); + + Assert.False(((ConfigurationSection)root.GetSection("RefToMissing")).TryGetValue(null, out string? missing)); + Assert.Null(missing); + } + + [Fact] + public void Subtree_GetChildren_SuppressesChildrenShadowedByHigherReference() + { + // A:B:X in a lower provider is shadowed by the higher A:B=ref(E), which owns the subtree and does not define + // X. GetChildren("A:B") must agree with the indexer (which resolves A:B:X to null), listing only C. + IConfigurationRoot root = BuildLayered( + Plain(("A:B:X", "5"), ("E:C:D", "2")), + Enabled(("A:B", "ref(E)"))); + + Assert.Null(root["A:B:X"]); + Assert.Equal("2", root["A:B:C:D"]); + Assert.Equal(new[] { "C" }, root.GetSection("A:B").GetChildren().Select(c => c.Key).ToArray()); + } + + [Fact] + public void Subtree_GetChildren_LowerChildAlsoInMirror_AppearsOnceWithMirrorValue() + { + // A:B:X exists in a lower provider (shadowed) and in the mirror target E:X. It appears once and resolves to + // the mirror's value, so enumeration and the indexer agree. + IConfigurationRoot root = BuildLayered( + Plain(("A:B:X", "low"), ("E:X", "fromE")), + Enabled(("A:B", "ref(E)"))); + + Assert.Equal("fromE", root["A:B:X"]); + Assert.Equal(new[] { "X" }, root.GetSection("A:B").GetChildren().Select(c => c.Key).ToArray()); + } + + // === Cycle detection (no depth bound) === + + [Theory] + [MemberData(nameof(RootKinds))] + public void Cycle_Direct_Throws(RootKind kind) + { + IConfigurationRoot root = BuildRoot(kind, Enabled( + ("A", "ref(B)"), + ("B", "ref(A)"))); + + Assert.Throws(() => _ = root["A"]); + } + + [Theory] + [MemberData(nameof(RootKinds))] + public void Cycle_Self_Throws(RootKind kind) + { + IConfigurationRoot root = BuildRoot(kind, Enabled(("Loop", "ref(Loop)"))); + + var ex = Assert.Throws(() => _ = root["Loop"]); + Assert.Contains("Loop", ex.Message); + } + + [Theory] + [MemberData(nameof(RootKinds))] + public void SelfReferential_TargetWithinOwnSubtree_Throws(RootKind kind) + { + // A reference whose target lies inside its own subtree would mirror forever (A -> A:B -> A:B:B -> ...), + // producing ever-growing keys that no cycle set can catch; it is rejected structurally rather than hanging. + IConfigurationRoot root = BuildRoot(kind, Enabled(("A", "ref(A:B)"))); + + var ex = Assert.Throws(() => _ = root["A"]); + Assert.Contains("A", ex.Message); + } + + [Fact] + public void Reference_SharingPrefixButNotAncestor_DoesNotThrow() + { + // AB is not within A's subtree (no ':' boundary), so A=ref(AB) is a normal reference, not self-referential. + IConfigurationRoot root = BuildRoot(RootKind.Builder, Enabled( + ("A", "ref(AB)"), + ("AB", "value"))); + + Assert.Equal("value", root["A"]); + } + + [Fact] + public void Cycle_Indirect_MessageNamesTheLoop() + { + IConfigurationRoot root = BuildRoot(RootKind.Builder, Enabled( + ("A", "ref(B)"), + ("B", "ref(C)"), + ("C", "ref(A)"))); + + var ex = Assert.Throws(() => _ = root["A"]); + Assert.Contains("A", ex.Message); + Assert.Contains("B", ex.Message); + Assert.Contains("C", ex.Message); + Assert.Contains("->", ex.Message); + } + + [Theory] + [MemberData(nameof(RootKinds))] + public void Cycle_GrowingIndirect_Throws(RootKind kind) + { + // A -> B with B -> A:C never resolves and never repeats a key: A:x mirrors to B:x to A:C:x to B:C:x to + // A:C:C:x, growing without bound. A repeat-based guard cannot catch it, but the reference graph does + // (A -> B -> A), so it is rejected rather than looped on. + IConfigurationRoot root = BuildRoot(kind, Enabled( + ("A", "ref(B)"), + ("B", "ref(A:C)"))); + + Assert.Throws(() => _ = root["A"]); + } + + [Theory] + [MemberData(nameof(RootKinds))] + public void Cycle_CrossSuffix_Growing_Throws(RootKind kind) + { + // The cycle only forms once a suffix is appended: A aliases to B, and B:C references back under A. Reading + // A:C mirrors A:C -> B:C -> A:C:D -> B:C:D -> A:C:D:D..., growing without bound. The first hop's target (B) + // is not itself a reference key, so a successor-graph that inspects only targets misses it; flattening the + // real chain (with suffixes) catches the key that redirects twice. + IConfigurationRoot root = BuildRoot(kind, Enabled( + ("A", "ref(B)"), + ("B:C", "ref(A:C:D)"))); + + Assert.Throws(() => _ = root["A:C"]); + } + + [Theory] + [MemberData(nameof(RootKinds))] + public void Cycle_CrossSuffix_Repeating_Throws(RootKind kind) + { + // A aliases to X, and X:B references A:B, so A:B mirrors A:B -> X:B -> A:B forever. Again the first hop's + // target (X) is not a reference key, so it is only caught by walking the real chain. + IConfigurationRoot root = BuildRoot(kind, Enabled( + ("A", "ref(X)"), + ("X:B", "ref(A:B)"))); + + Assert.Throws(() => _ = root["A:B"]); + } + + [Fact] + public void Cycle_DoesNotAffectUnrelatedKeys_ButThrowsWhenReadInto() + { + // Cycles are detected by the resolution walk, not up front, so a cycle only surfaces when a read actually + // walks into it. An unrelated key - and an unrelated healthy reference - resolve normally even though a + // cycle exists elsewhere; only a read that walks into the cycle throws. + IConfigurationRoot root = BuildRoot(RootKind.Builder, Enabled( + ("Unrelated", "value"), + ("Healthy", "ref(Unrelated)"), + ("A", "ref(B)"), + ("B", "ref(A)"))); + + Assert.Equal("value", root["Unrelated"]); + Assert.Equal("value", root["Healthy"]); + Assert.Throws(() => _ = root["A"]); + } + + [Fact] + public void Cycle_GetChildren_Throws() + { + // A section-reference cycle is caught during enumeration (thrown, not looped forever). + IConfigurationRoot root = BuildRoot(RootKind.Builder, Enabled( + ("A:Sub", "x"), + ("A", "ref(B)"), + ("B", "ref(A)"))); + + Assert.Throws(() => root.GetSection("A").GetChildren().ToList()); + } + + [Fact] + public void DeepChain_ResolvesWithoutDepthLimit() + { + // No depth cap: a legitimate chain far longer than any old bound must still resolve. + var data = new Dictionary(System.StringComparer.OrdinalIgnoreCase); + const int Hops = 500; + for (int i = 0; i < Hops; i++) + { + data[$"R{i}:$ref"] = $"R{i + 1}"; + } + data[$"R{Hops}"] = "final"; + + IConfigurationRoot root = new ConfigurationBuilder() + .Add(new MemoryConfigurationSource { InitialData = data }) + .Build(); + + Assert.Equal("final", root["R0"]); + } + + [Fact] + public void DeepChain_GetChildren_DoesNotOverflowStack() + { + // Enumeration follows the mirror chain iteratively, so a deep (finite) chain of section references must not + // overflow the stack: R0=ref(R1), ..., R{N-1}=ref(R{N}); R{N} has a child. + var data = new Dictionary(System.StringComparer.OrdinalIgnoreCase); + const int Hops = 5000; + for (int i = 0; i < Hops; i++) + { + data[$"R{i}:$ref"] = $"R{i + 1}"; + } + data[$"R{Hops}:Leaf"] = "deep"; + + IConfigurationRoot root = new ConfigurationBuilder() + .Add(new MemoryConfigurationSource { InitialData = data }) + .Build(); + + Assert.Equal(new[] { "Leaf" }, root.GetSection("R0").GetChildren().Select(c => c.Key).ToArray()); + } + + // === Scope: a reference resolves against the effective (fully merged) configuration === + + [Fact] + public void Reference_ResolvesToEffectiveValue_TargetInLowerProvider() + { + // Client=ref(Shared) in the higher provider; Shared is in the lower provider. + IConfigurationRoot root = BuildLayered( + Plain(("Shared", "secret")), + Enabled(("Client", "ref(Shared)"))); + + Assert.Equal("secret", root["Client"]); + } + + [Fact] + public void Reference_ResolvesToEffectiveValue_TargetInHigherProvider() + { + // Client=ref(Shared) in the lower provider; Shared is only in the higher provider. A reference resolves + // against the effective configuration, so it still sees the higher Shared. + IConfigurationRoot root = BuildLayered( + Enabled(("Client", "ref(Shared)")), + Plain(("Shared", "secret"))); + + Assert.Equal("secret", root["Client"]); + } + + [Fact] + public void Reference_PicksUpHigherProviderOverrideOfTarget() + { + // The base + environment-override pattern: a reference declared in the base file picks up a higher + // provider's override of its target (ref(X) == reading X). + IConfigurationRoot root = BuildLayered( + Enabled(("Shared:Conn", "dev"), ("Db:Conn", "ref(Shared:Conn)")), + Plain(("Shared:Conn", "prod"))); + + Assert.Equal("prod", root["Db:Conn"]); + } + + // === Redirection: shallowest reference wins; a ref key still respects provider precedence === + + [Fact] + public void HigherAncestorReference_OverridesLowerDescendantValue() + { + IConfigurationRoot root = BuildLayered( + Plain(("A:B:C:D", "1"), ("E:C:D", "2")), + Enabled(("A:B", "ref(E)"))); + + Assert.Equal("2", root["A:B:C:D"]); + } + + [Fact] + public void ShallowestReferenceOnPathWins_NestedReferenceIgnored() + { + // Both A and A:B are references; the shallower A wins and redirects the whole subtree, so A:B:C reads X:B:C. + // The nested A:B reference is inside A's alias and is ignored. + IConfigurationRoot root = BuildRoot(RootKind.Builder, Enabled( + ("A", "ref(X)"), + ("A:B", "ref(Y)"), + ("X:B:C", "fromX"), + ("Y:C", "fromY"))); + + Assert.Equal("fromX", root["A:B:C"]); + } + + [Fact] + public void Reference_HigherDescendantValue_OverridesMirror() + { + // A:B aliases its subtree to E, but a provider above the reference overrides the individual key A:B:C:D, + // which wins over the mirrored E:C:D (recursive merge patches keys inside the referenced subtree). + IConfigurationRoot root = BuildLayered( + Enabled(("A:B", "ref(E)"), ("E:C:D", "2")), + Plain(("A:B:C:D", "1"))); + + Assert.Equal("1", root["A:B:C:D"]); + } + + [Fact] + public void HigherReference_MirrorAbsent_ResolvesToNull() + { + // The fallback is dropped: a higher reference owns its subtree, so a descendant the target does not define + // resolves to null rather than surfacing the lower value. + IConfigurationRoot root = BuildLayered( + Plain(("A:B:X", "5"), ("E:C:D", "2")), + Enabled(("A:B", "ref(E)"))); + + Assert.Equal("2", root["A:B:C:D"]); + Assert.Null(root["A:B:X"]); + } + + [Fact] + public void Reference_RedirectsSubtree_IgnoringSameProviderDescendantValue() + { + // Even within one provider, A:B redirects the whole subtree to E; the direct A:B:C:D is inside the alias and + // is ignored. + IConfigurationRoot root = BuildRoot(RootKind.Builder, Enabled( + ("E:C:D", "2"), + ("A:B", "ref(E)"), + ("A:B:C:D", "1"))); + + Assert.Equal("2", root["A:B:C:D"]); + } + + [Fact] + public void HigherScalarOverride_OverridesValueButSubtreeStillMirrors() + { + // A lower provider makes A:B a reference to section E; a higher provider overrides A:B's own scalar value. + // The override wins for A:B's value, but references merge rather than suppress, so the mirrored subtree + // (A:B:X, A:B:Y from E) is still visible. + IConfigurationRoot root = BuildLayered( + Enabled(("A:B", "ref(E)"), ("E:X", "5"), ("E:Y", "6")), + Plain(("A:B", "literal"))); + + Assert.Equal("literal", root["A:B"]); + Assert.Equal("5", root["A:B:X"]); + Assert.Equal("6", root["A:B:Y"]); + } + + [Fact] + public void Reference_HigherDescendantOverride_WinsWhileSiblingsMirror() + { + // A provider above the reference overrides one descendant (A:B:X); that key wins over the mirror while its + // siblings still mirror the target: A:B:X reads the override, A:B:Y reads E:Y. + IConfigurationRoot root = BuildLayered( + Enabled(("A:B", "ref(E)"), ("E:X", "5"), ("E:Y", "6")), + Plain(("A:B:X", "override"))); + + Assert.Equal("override", root["A:B:X"]); + Assert.Equal("6", root["A:B:Y"]); + } + + [Theory] + [MemberData(nameof(RootKinds))] + public void HigherDescendantReference_WinsOverLowerAncestorReference(RootKind kind) + { + // A lower opted-in provider aliases the whole A subtree to X, but a higher opted-in provider declares a + // more specific reference at A:B. Provider precedence beats prefix depth, so the A:B subtree resolves + // through the higher A:B -> Y reference, not the lower A -> X one. Reading A:B must resolve Y's value (not + // leak the raw "ref(Y)" marker), and A:B:C must mirror Y:C rather than X:B:C. + IConfigurationRoot root = BuildRoot(kind, new[] + { + Enabled(("A", "ref(X)"), ("X:B:C", "lower")), + Enabled(("A:B", "ref(Y)"), ("Y", "yval"), ("Y:C", "higher")), + }); + + Assert.Equal("yval", root["A:B"]); + Assert.Equal("higher", root["A:B:C"]); + Assert.Equal(new[] { "C" }, root.GetSection("A:B").GetChildren().Select(c => c.Key).ToArray()); + } + + // === Recursive merge: overrides apply at every hop of a chain === + + [Theory] + [InlineData("X:Y:A:B:C", "1")] // patch on the outermost reference's subtree + [InlineData("X:Y:A:B:F", "2")] // patch on the interior reference (D)'s subtree - only recursive merge sees this + [InlineData("X:Y:A:B:I", "3")] // no patch: mirrors the final target G + public void RecursiveMerge_PatchesEveryHop(string key, string expected) + { + // Chain X:Y:A -> D -> G, where D is itself a reference. A higher provider patches a key on the outermost + // subtree (X:Y:A:B:C) and one on the interior subtree (D:B:F). Recursive merge honours both; the untouched + // path mirrors G. + IConfigurationRoot root = BuildLayered( + Enabled(("X:Y:A", "ref(D)"), ("D", "ref(G)"), ("G:B:I", "3")), + Plain(("X:Y:A:B:C", "1"), ("D:B:F", "2"))); + + Assert.Equal(expected, root[key]); + } + + [Fact] + public void RecursiveMerge_GetChildren_UnionsOverridesAtEveryHop() + { + // GetChildren agrees with the reads: the children of X:Y:A:B are the outermost patch (C), the interior patch + // (F) and the mirrored target key (I). + IConfigurationRoot root = BuildLayered( + Enabled(("X:Y:A", "ref(D)"), ("D", "ref(G)"), ("G:B:I", "3")), + Plain(("X:Y:A:B:C", "1"), ("D:B:F", "2"))); + + Assert.Equal(new[] { "C", "F", "I" }, + root.GetSection("X:Y:A:B").GetChildren().Select(c => c.Key).OrderBy(k => k).ToArray()); + } + + [Theory] + [MemberData(nameof(RootKinds))] + public void RecursiveMerge_GetChildren_SortsMergedChildrenByConfigurationKeyComparer(RootKind kind) + { + // The children of a resolved section are a union of a higher provider's overrides and the mirrored target's + // children, each block arriving sorted on its own. GetChildren must return the union in + // ConfigurationKeyComparer order (numeric-aware: "2" before "10"), not in block-concatenation order. + IConfigurationRoot root = BuildRoot(kind, new[] + { + Enabled(("A", "ref(Target)"), ("Target:2", "two")), + Plain(("A:10", "ten")), + }); + + Assert.Equal(new[] { "2", "10" }, root.GetSection("A").GetChildren().Select(c => c.Key).ToArray()); + Assert.Equal("two", root["A:2"]); + Assert.Equal("ten", root["A:10"]); + } + + [Fact] + public void RecursiveMerge_AliasMirrorsTargetFaithfully() + { + // X:Y:A aliases D, so reading through X:Y:A matches reading D directly, including a higher provider's patch + // that lands inside the interior reference D. (Under a single-level merge these would diverge.) + IConfigurationRoot root = BuildLayered( + Enabled(("X:Y:A", "ref(D)"), ("D", "ref(G)"), ("G:B:I", "3")), + Plain(("D:B:F", "2"))); + + Assert.Equal("2", root["D:B:F"]); + Assert.Equal(root["D:B:F"], root["X:Y:A:B:F"]); + } + + // === Reload === + + [Fact] + public void Reload_ReflectsChangedTargetValue_AndFiresToken() + { + var source = new ReloadableMemorySource + { + InitialData = new[] + { + new KeyValuePair("Shared:Credential", "old"), + new KeyValuePair("Client:Credential:$ref", "Shared:Credential"), + } + }; + var builder = new ConfigurationBuilder(); + builder.Add(source); + IConfigurationRoot root = builder.Build(); + Assert.Equal("old", root["Client:Credential"]); + + bool fired = false; + ChangeToken.OnChange(root.GetReloadToken, () => fired = true); + + source.Built!.Set("Shared:Credential", "new"); + source.Built!.TriggerReload(); + + Assert.True(fired); + Assert.Equal("new", root["Client:Credential"]); + } + + // === ConfigurationManager === + + [Fact] + public void Manager_IndexerSetsReference_ResolvedOnRead() + { + using var manager = new ConfigurationManager(); + + manager["Shared:Credential"] = "secret"; + manager["Client:Credential:$ref"] = "Shared:Credential"; + + Assert.Equal("secret", manager["Client:Credential"]); + } + + [Fact] + public void Manager_IndexerAddsReferenceAfterRead_TakesEffect() + { + using var manager = new ConfigurationManager(); + manager["Plain"] = "x"; + _ = manager["Plain"]; // prime the index while there are no references (caches an empty index) + + manager["Shared"] = "secret"; + manager["Client:$ref"] = "Shared"; + + Assert.Equal("secret", manager["Client"]); + } + + [Fact] + public void Root_IndexerChangesReferenceTarget_TakesEffect() + { + IConfigurationRoot root = BuildRoot(RootKind.Builder, Enabled( + ("A", "ref(B)"), + ("B", "vB"), + ("C", "vC"))); + Assert.Equal("vB", root["A"]); // prime the index (A -> B) + + root["A:$ref"] = "C"; + + Assert.Equal("vC", root["A"]); + } + + [Fact] + public void Manager_ConcurrentSourceAddWhileReadingReferences_DoesNotThrow() + { + using var manager = new ConfigurationManager(); + var builder = (IConfigurationBuilder)manager; + + // Enable references and warm the cache so the opted-in flags are memoised at the current provider count. + builder.Add(Enabled(("Shared:Credential", "secret"), ("Client:Credential", "ref(Shared:Credential)"))); + Assert.Equal("secret", manager["Client:Credential"]); + + System.Exception? failure = null; + bool done = false; + using var started = new ManualResetEventSlim(false); + + var reader = new Thread(() => + { + started.Set(); + try + { + while (!Volatile.Read(ref done)) + { + // "Probe" is redefined by every added provider, so its winning provider is a high index - the + // exact shape that paired an old (short) opted-in array with a longer provider snapshot and threw + // IndexOutOfRangeException before the cache was bound to the pinned provider generation. + _ = manager["Probe"]; + _ = manager["Client:Credential"]; + } + } + catch (System.Exception ex) + { + failure = ex; + } + }); + + reader.Start(); + started.Wait(); + + for (int i = 0; i < 300; i++) + { + builder.Add(Plain(("Probe", "v" + i))); + } + + Volatile.Write(ref done, true); + reader.Join(); + + Assert.Null(failure); + Assert.Equal("secret", manager["Client:Credential"]); + } + + [Fact] + public void Manager_ReadAfterDispose_WithReferences_DoesNotThrow() + { + // Reading a ConfigurationManager after it is disposed is deliberately tolerated (see + // DisposedReferenceCountedProviders): ConfigurationSection.TryGetValue skips a provider that throws + // ObjectDisposedException and returns false. Building the reference index scans providers too, so it must + // tolerate the same rather than letting the exception escape that read. + var manager = new ConfigurationManager(); + ((IConfigurationBuilder)manager).Add(new ThrowOnDisposeSource(Dict(references: true, new (string, string?)[] + { + ("Shared:Credential", "secret"), + ("Client:Credential", "ref(Shared:Credential)"), + }))); + + var section = new ConfigurationSection(manager, "Client"); + Assert.True(section.TryGetValue("Credential", out string? value)); + Assert.Equal("secret", value); + + manager.Dispose(); + + Assert.False(section.TryGetValue("Credential", out value)); + Assert.Null(value); + } + + [Fact] + public void NonScannableProvider_ReferenceResolves() + { + IConfigurationRoot root = BuildLayered( + Unscannable( + ("Shared:Credential", "secret"), + ("Client:Credential", "ref(Shared:Credential)"))); + + Assert.Equal("secret", root["Client:Credential"]); + } + + [Fact] + public void Reference_TargetInChainedConfiguration_Resolves() + { + // A chained (AddConfiguration) provider is a plain value source to the outer engine, so an outer reference + // can target its keys. + IConfigurationRoot inner = new ConfigurationBuilder() + .Add(Plain(("Shared:Conn", "dev"))) + .Build(); + + IConfigurationRoot outer = new ConfigurationBuilder() + .AddConfiguration(inner) + .Add(Enabled(("Db:Conn", "ref(Shared:Conn)"))) + .Build(); + + Assert.Equal("dev", outer["Db:Conn"]); + } + + [Fact] + public void RefShapedValueInChainedConfig_StaysLiteral() + { + // References are declared with a $ref key, so a ref(...)-shaped value is a plain literal - inside a chained + // config and through the outer engine alike. + IConfigurationRoot inner = new ConfigurationBuilder() + .Add(Plain(("Literal", "ref(Target)"))) + .Build(); + + Assert.Equal("ref(Target)", inner["Literal"]); + + IConfigurationRoot outer = new ConfigurationBuilder() + .AddConfiguration(inner) + .Build(); + + Assert.Equal("ref(Target)", outer["Literal"]); + } + + // === Third-party root === + + [Fact] + public void GetChildren_ThirdPartyRoot_EnumeratesWithoutResolving() + { + // A root that is neither ConfigurationRoot nor ConfigurationManager exposes no reference engine, so there is + // nothing to resolve against. GetChildren must still enumerate the section's children (references simply do + // not apply to such a root) rather than dereferencing a null engine. + IConfigurationRoot inner = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Parent:ChildA"] = "1", + ["Parent:ChildB"] = "2", + }) + .Build(); + + IConfigurationSection section = new ConfigurationSection(new ThirdPartyRoot(inner), "Parent"); + + string[] children = section.GetChildren().Select(c => c.Key).OrderBy(k => k).ToArray(); + Assert.Equal(new[] { "ChildA", "ChildB" }, children); + } + + // === Helpers === + + private static IConfigurationRoot BuildRoot(RootKind kind, IConfigurationSource source) => BuildRoot(kind, new[] { source }); + + private static IConfigurationRoot BuildRoot(RootKind kind, IConfigurationSource[] sources) + { + if (kind == RootKind.Builder) + { + var builder = new ConfigurationBuilder(); + foreach (IConfigurationSource source in sources) + { + builder.Add(source); + } + return builder.Build(); + } + + var manager = new ConfigurationManager(); + foreach (IConfigurationSource source in sources) + { + ((IConfigurationBuilder)manager).Add(source); + } + return manager; + } + + private static IConfigurationRoot BuildLayered(params IConfigurationSource[] sources) + { + var builder = new ConfigurationBuilder(); + foreach (IConfigurationSource source in sources) + { + builder.Add(source); + } + return builder.Build(); + } + + private static IConfigurationSource Enabled(params (string Key, string? Value)[] entries) + => new MemoryConfigurationSource { InitialData = Dict(references: true, entries) }; + + private static IConfigurationSource Plain(params (string Key, string? Value)[] entries) + => new MemoryConfigurationSource { InitialData = Dict(references: false, entries) }; + + private static IConfigurationSource Unscannable(params (string Key, string? Value)[] entries) + => new UnscannableSource(Dict(references: true, entries)); + + // Tests author references inline as ("Path", "ref(Target)") for readability. The model declares a reference with + // a reserved "$ref" child key, so a ref(...) authoring entry becomes "Path:$ref = Target"; when references is + // false the entry is kept verbatim (a ref(...) value is then just a literal, as it always is in this model). + private static IDictionary Dict(bool references, (string Key, string? Value)[] entries) + { + var dictionary = new Dictionary(System.StringComparer.OrdinalIgnoreCase); + foreach ((string key, string? value) in entries) + { + if (references && TryUnwrapRef(value, out string? target)) + { + dictionary[key + ConfigurationPath.KeyDelimiter + "$ref"] = target; + } + else + { + dictionary[key] = value; + } + } + return dictionary; + } + + private static bool TryUnwrapRef(string? value, out string? target) + { + if (value is not null && value.StartsWith("ref(", System.StringComparison.Ordinal) && value.EndsWith(")", System.StringComparison.Ordinal)) + { + target = value.Substring(4, value.Length - 5).Trim(); + return target.Length != 0; + } + target = null; + return false; + } + + // A provider that implements IConfigurationProvider directly (not via ConfigurationProvider), so the reference + // engine must scan it via GetChildKeys rather than its loaded dictionary. + private sealed class UnscannableSource : IConfigurationSource + { + private readonly IDictionary _data; + + public UnscannableSource(IDictionary data) => _data = data; + + public IConfigurationProvider Build(IConfigurationBuilder builder) => new UnscannableProvider(_data); + } + + private sealed class UnscannableProvider : IConfigurationProvider + { + private readonly Dictionary _data; + private readonly ConfigurationReloadToken _reloadToken = new(); + + public UnscannableProvider(IDictionary data) + => _data = new Dictionary(data, System.StringComparer.OrdinalIgnoreCase); + + public bool TryGet(string key, out string? value) => _data.TryGetValue(key, out value); + + public void Set(string key, string? value) => _data[key] = value; + + public IChangeToken GetReloadToken() => _reloadToken; + + public void Load() { } + + public IEnumerable GetChildKeys(IEnumerable earlierKeys, string? parentPath) + { + string prefix = parentPath is null ? string.Empty : parentPath + ConfigurationPath.KeyDelimiter; + var results = new List(earlierKeys); + foreach (KeyValuePair entry in _data) + { + if (entry.Key.StartsWith(prefix, System.StringComparison.OrdinalIgnoreCase)) + { + string rest = entry.Key.Substring(prefix.Length); + int delimiter = rest.IndexOf(ConfigurationPath.KeyDelimiter[0]); + results.Add(delimiter < 0 ? rest : rest.Substring(0, delimiter)); + } + } + return results; + } + } + + private sealed class ReloadableMemoryProvider : MemoryConfigurationProvider + { + public ReloadableMemoryProvider(MemoryConfigurationSource source) : base(source) { } + + public void TriggerReload() => OnReload(); + } + + // A provider that throws ObjectDisposedException from every read once disposed, to model a provider that holds + // native/OS resources. Used to check that reading a disposed ConfigurationManager tolerates such a provider. + private sealed class ThrowOnDisposeSource : IConfigurationSource + { + private readonly IDictionary _data; + + public ThrowOnDisposeSource(IDictionary data) => _data = data; + + public IConfigurationProvider Build(IConfigurationBuilder builder) => new ThrowOnDisposeProvider(_data); + } + + private sealed class ThrowOnDisposeProvider : IConfigurationProvider, System.IDisposable + { + private readonly Dictionary _data; + private readonly ConfigurationReloadToken _reloadToken = new(); + private bool _disposed; + + public ThrowOnDisposeProvider(IDictionary data) + => _data = new Dictionary(data, System.StringComparer.OrdinalIgnoreCase); + + public void Dispose() => _disposed = true; + + public bool TryGet(string key, out string? value) + { + ThrowIfDisposed(); + return _data.TryGetValue(key, out value); + } + + public void Set(string key, string? value) + { + ThrowIfDisposed(); + _data[key] = value; + } + + public IChangeToken GetReloadToken() => _reloadToken; + + public void Load() { } + + public IEnumerable GetChildKeys(IEnumerable earlierKeys, string? parentPath) + { + ThrowIfDisposed(); + string prefix = parentPath is null ? string.Empty : parentPath + ConfigurationPath.KeyDelimiter; + var results = new List(earlierKeys); + foreach (KeyValuePair entry in _data) + { + if (entry.Key.StartsWith(prefix, System.StringComparison.OrdinalIgnoreCase)) + { + string rest = entry.Key.Substring(prefix.Length); + int delimiter = rest.IndexOf(ConfigurationPath.KeyDelimiter[0]); + results.Add(delimiter < 0 ? rest : rest.Substring(0, delimiter)); + } + } + return results; + } + + private void ThrowIfDisposed() + { + if (_disposed) + { + throw new System.ObjectDisposedException(nameof(ThrowOnDisposeProvider)); + } + } + } + + private sealed class ReloadableMemorySource : MemoryConfigurationSource, IConfigurationSource + { + public ReloadableMemoryProvider? Built { get; private set; } + + public new IConfigurationProvider Build(IConfigurationBuilder builder) + { + Built = new ReloadableMemoryProvider(this); + if (InitialData is not null) + { + foreach (KeyValuePair pair in InitialData) + { + Built.Set(pair.Key, pair.Value); + } + } + return Built; + } + } + + // A minimal third-party IConfigurationRoot: neither ConfigurationRoot nor ConfigurationManager, so it exposes no + // reference engine. Delegates everything to a real root so the reference-aware read paths can be exercised + // against a root type they do not recognise. + private sealed class ThirdPartyRoot : IConfigurationRoot + { + private readonly IConfigurationRoot _inner; + + public ThirdPartyRoot(IConfigurationRoot inner) => _inner = inner; + + public string? this[string key] + { + get => _inner[key]; + set => _inner[key] = value; + } + + public IEnumerable Providers => _inner.Providers; + + public IEnumerable GetChildren() => _inner.GetChildren(); + + public IChangeToken GetReloadToken() => _inner.GetReloadToken(); + + public IConfigurationSection GetSection(string key) => _inner.GetSection(key); + + public void Reload() => _inner.Reload(); + } + } +} diff --git a/src/libraries/Microsoft.Extensions.Configuration/tests/Microsoft.Extensions.Configuration.Tests.csproj b/src/libraries/Microsoft.Extensions.Configuration/tests/Microsoft.Extensions.Configuration.Tests.csproj index 91ef2601d28568..7bf1c191f2ae78 100644 --- a/src/libraries/Microsoft.Extensions.Configuration/tests/Microsoft.Extensions.Configuration.Tests.csproj +++ b/src/libraries/Microsoft.Extensions.Configuration/tests/Microsoft.Extensions.Configuration.Tests.csproj @@ -2,6 +2,7 @@ $(NetCoreAppCurrent);$(NetFrameworkCurrent) + true