From eaab143a518903cf3bf6ef2f0603ea2d7edbea04 Mon Sep 17 00:00:00 2001 From: Bernhard Windisch Date: Tue, 2 Jun 2026 16:19:48 +0200 Subject: [PATCH 01/10] fix(observable): non-blocking one-shot fetch, no subscription leak FetchConfigurationBytesAsync had no OnCompleted handler (cold/complete-without-emit source hung ConfigManager.Create) and disposed its subscription via a still-null reference on synchronous replay (leak per fetch). Now: take the synchronously replayed value, else degrade to {} immediately (ChangesAsBytes delivers real values reactively), handle OnCompleted, and always dispose the subscription. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ObservableProvider/ObservableProvider.cs | 21 ++++- .../Providers/ObservableProviderFetchTests.cs | 93 +++++++++++++++++++ 2 files changed, 109 insertions(+), 5 deletions(-) create mode 100644 src/tests/Cocoar.Configuration.Core.Tests/Providers/ObservableProviderFetchTests.cs diff --git a/src/Cocoar.Configuration/Providers/ObservableProvider/ObservableProvider.cs b/src/Cocoar.Configuration/Providers/ObservableProvider/ObservableProvider.cs index 171a759..b31cb7b 100644 --- a/src/Cocoar.Configuration/Providers/ObservableProvider/ObservableProvider.cs +++ b/src/Cocoar.Configuration/Providers/ObservableProvider/ObservableProvider.cs @@ -9,12 +9,23 @@ public sealed class ObservableProvider(ObservableProviderOptions options) { public override async Task FetchConfigurationBytesAsync(ObservableProviderQuery query, CancellationToken ct = default) { - var tcs = new TaskCompletionSource(); + // Fetch is a one-shot, non-blocking snapshot: take whatever the source replays synchronously + // (e.g. a BehaviorSubject), otherwise degrade to an empty object and let ChangesAsBytes deliver + // real values reactively. This avoids hanging the recompute on a cold/late source, handles + // OnCompleted-without-emit, and always disposes the subscription (no leak even on synchronous replay). + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); using var ctr = ct.Register(() => tcs.TrySetCanceled(ct)); - IDisposable? sub = null; - sub = ProviderOptions.Observable.Subscribe( - value => { tcs.TrySetResult(ConvertToBytes(value)); sub?.Dispose(); }, - ex => { tcs.TrySetException(ex); sub?.Dispose(); }); + + var sub = ProviderOptions.Observable.Subscribe( + value => tcs.TrySetResult(ConvertToBytes(value)), + ex => tcs.TrySetException(ex), + () => tcs.TrySetResult("{}"u8.ToArray())); + + // No synchronous value (cold/late source): return an empty snapshot now. The reactive stream + // (ChangesAsBytes) will trigger a recompute when the first real value arrives. + tcs.TrySetResult("{}"u8.ToArray()); + sub.Dispose(); + return await tcs.Task.ConfigureAwait(false); } diff --git a/src/tests/Cocoar.Configuration.Core.Tests/Providers/ObservableProviderFetchTests.cs b/src/tests/Cocoar.Configuration.Core.Tests/Providers/ObservableProviderFetchTests.cs new file mode 100644 index 0000000..9c41fd2 --- /dev/null +++ b/src/tests/Cocoar.Configuration.Core.Tests/Providers/ObservableProviderFetchTests.cs @@ -0,0 +1,93 @@ +using System.Text; +using System.Reactive.Linq; +using System.Reactive.Subjects; +using Cocoar.Configuration.Providers; + +namespace Cocoar.Configuration.Core.Tests.Providers; + +/// +/// Regression tests for .FetchConfigurationBytesAsync — the fetch path +/// must never hang and must always dispose its one-shot subscription. +/// +public class ObservableProviderFetchTests +{ + public record Cfg(string Name = "", int Value = 0); + + private static async Task FetchJson(IObservable source) + { + var provider = new ObservableProvider(new ObservableProviderOptions(source)); + var bytes = await provider.FetchConfigurationBytesAsync(ObservableProviderQuery.Default); + return Encoding.UTF8.GetString(bytes); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "ObservableProvider")] + public async Task Fetch_does_not_hang_on_a_cold_source_that_never_emits() + { + using var cold = new Subject(); // never emits, never completes + + var fetch = FetchJson(cold); + var finishedInTime = await Task.WhenAny(fetch, Task.Delay(2000)) == fetch; + + Assert.True(finishedInTime, "Fetch must not block on a source that has not emitted."); + Assert.Equal("{}", await fetch); // empty snapshot; ChangesAsBytes delivers real values reactively + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "ObservableProvider")] + public async Task Fetch_returns_empty_when_source_completes_without_emitting() + { + Assert.Equal("{}", await FetchJson(Observable.Empty())); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "ObservableProvider")] + public async Task Fetch_takes_the_synchronously_replayed_value() + { + using var subject = new BehaviorSubject(new Cfg("hello", 42)); + var json = await FetchJson(subject); + Assert.Contains("\"Name\":\"hello\"", json); + Assert.Contains("\"Value\":42", json); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "ObservableProvider")] + public async Task Fetch_disposes_its_subscription_on_synchronous_replay() + { + var tracking = new TrackingObservable(new Cfg("x", 1)); + + await FetchJson(tracking); + + Assert.Equal(0, tracking.ActiveSubscriptions); // disposed → no leak + } + + /// An observable that replays a value synchronously on subscribe and tracks live subscriptions. + private sealed class TrackingObservable(T replayValue) : IObservable + { + private int _active; + public int ActiveSubscriptions => Volatile.Read(ref _active); + + public IDisposable Subscribe(IObserver observer) + { + Interlocked.Increment(ref _active); + observer.OnNext(replayValue); // synchronous emission, before Subscribe returns + return new Unsubscriber(this); + } + + private sealed class Unsubscriber(TrackingObservable owner) : IDisposable + { + private int _disposed; + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) == 0) + { + Interlocked.Decrement(ref owner._active); + } + } + } + } +} From 810ea63ff8a78e0e7ed0a254b193446a17981249 Mon Sep 17 00:00:00 2001 From: Bernhard Windisch Date: Tue, 2 Jun 2026 16:27:09 +0200 Subject: [PATCH 02/10] fix(http): dispose response in FetchAsync; configurable SSE read-idle timeout + SSE tests FetchAsync never disposed its HttpResponseMessage (connection/response leak per fetch/poll) -> using var resp. Added optional HttpProviderOptions.SseReadIdleTimeout (default null = off): when set, a half-open SSE stream that goes idle is treated as dead and reconnected with backoff (HttpClient.Timeout does not cover the streamed body). Added the first SSE tests (data-line emission, idle-timeout reconnect via a blocking stream). Also made the test QueueHandler return a fresh response per call (real handlers never reuse a disposable response; the old mock reused one instance and broke once responses are correctly disposed). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Cocoar.Configuration.Http/HttpProvider.cs | 2 +- .../HttpProviderOptions.cs | 12 ++- .../HttpRuleOptions.cs | 15 ++- .../SseObservable.cs | 25 ++++- .../Http/HttpProviderSmokeTests.cs | 27 ++--- .../Http/SseObservableTests.cs | 101 ++++++++++++++++++ 6 files changed, 161 insertions(+), 21 deletions(-) create mode 100644 src/tests/Cocoar.Configuration.Providers.Tests/Http/SseObservableTests.cs diff --git a/src/Cocoar.Configuration.Http/HttpProvider.cs b/src/Cocoar.Configuration.Http/HttpProvider.cs index 299d5c8..691bb95 100644 --- a/src/Cocoar.Configuration.Http/HttpProvider.cs +++ b/src/Cocoar.Configuration.Http/HttpProvider.cs @@ -104,7 +104,7 @@ internal async Task FetchAsync(HttpProviderQueryOptions query, Cancellat using var req = new HttpRequestMessage(HttpMethod.Get, url); ApplyHeaders(req, query.Headers); - var resp = await client.SendAsync(req, ct).ConfigureAwait(false); + using var resp = await client.SendAsync(req, ct).ConfigureAwait(false); resp.EnsureSuccessStatusCode(); return await resp.Content.ReadAsByteArrayAsync(ct).ConfigureAwait(false); } diff --git a/src/Cocoar.Configuration.Http/HttpProviderOptions.cs b/src/Cocoar.Configuration.Http/HttpProviderOptions.cs index 369691f..b986db5 100644 --- a/src/Cocoar.Configuration.Http/HttpProviderOptions.cs +++ b/src/Cocoar.Configuration.Http/HttpProviderOptions.cs @@ -30,6 +30,14 @@ public sealed class HttpProviderOptions : IProviderConfiguration /// public int ErrorConsecutiveFailureThreshold { get; } + /// + /// When set, an SSE connection that receives no data within this window is treated as dead and + /// reconnected (with backoff). Default null = no read-idle timeout. Only meaningful when + /// is true. Set this only if your SSE server sends periodic data or + /// heartbeat comments; otherwise a legitimately idle config stream would reconnect needlessly. + /// + public TimeSpan? SseReadIdleTimeout { get; } + /// /// Optional custom handler for the underlying HttpClient. Not serialized for provider key generation. /// @@ -53,7 +61,8 @@ public HttpProviderOptions( TimeSpan? fallbackPollInterval = null, int errorConsecutiveFailureThreshold = 3, HttpMessageHandler? handler = null, - Func? clientFactory = null) + Func? clientFactory = null, + TimeSpan? sseReadIdleTimeout = null) { PollInterval = pollInterval; ServerSentEvents = serverSentEvents; @@ -61,6 +70,7 @@ public HttpProviderOptions( ErrorConsecutiveFailureThreshold = errorConsecutiveFailureThreshold <= 0 ? 3 : errorConsecutiveFailureThreshold; Handler = handler; ClientFactory = clientFactory; + SseReadIdleTimeout = sseReadIdleTimeout; } private static readonly JsonSerializerOptions ProviderKeyOptions = new() diff --git a/src/Cocoar.Configuration.Http/HttpRuleOptions.cs b/src/Cocoar.Configuration.Http/HttpRuleOptions.cs index e151ec4..58db60a 100644 --- a/src/Cocoar.Configuration.Http/HttpRuleOptions.cs +++ b/src/Cocoar.Configuration.Http/HttpRuleOptions.cs @@ -44,6 +44,14 @@ public sealed class HttpRuleOptions /// public int ErrorConsecutiveFailureThreshold { get; } + /// + /// When set, an SSE connection that receives no data within this window is treated as dead and + /// reconnected (with backoff). Default null = no read-idle timeout. Only meaningful when + /// is true. Set this only if your SSE server sends periodic data or + /// heartbeat comments; otherwise a legitimately idle config stream would reconnect needlessly. + /// + public TimeSpan? SseReadIdleTimeout { get; } + public HttpRuleOptions( string url, TimeSpan? pollInterval = null, @@ -51,7 +59,8 @@ public HttpRuleOptions( TimeSpan? fallbackPollInterval = null, IReadOnlyDictionary? headers = null, HttpMessageHandler? handler = null, - int errorConsecutiveFailureThreshold = 3) + int errorConsecutiveFailureThreshold = 3, + TimeSpan? sseReadIdleTimeout = null) { if (string.IsNullOrWhiteSpace(url)) { @@ -65,6 +74,7 @@ public HttpRuleOptions( Headers = headers; Handler = handler; ErrorConsecutiveFailureThreshold = errorConsecutiveFailureThreshold <= 0 ? 3 : errorConsecutiveFailureThreshold; + SseReadIdleTimeout = sseReadIdleTimeout; } internal HttpProviderOptions ToProviderOptions() => new( @@ -72,7 +82,8 @@ public HttpRuleOptions( ServerSentEvents, FallbackPollInterval, ErrorConsecutiveFailureThreshold, - Handler); + Handler, + sseReadIdleTimeout: SseReadIdleTimeout); internal HttpProviderQueryOptions ToQueryOptions() => new(Url, Headers); } diff --git a/src/Cocoar.Configuration.Http/SseObservable.cs b/src/Cocoar.Configuration.Http/SseObservable.cs index bf00bf1..ef607a0 100644 --- a/src/Cocoar.Configuration.Http/SseObservable.cs +++ b/src/Cocoar.Configuration.Http/SseObservable.cs @@ -106,9 +106,32 @@ private async Task ConnectAndReadSseAsync(IObserver observer, Cancellati // Reset failure counters on successful connection provider.ResetFailureCount(); + var idleTimeout = provider.Options.SseReadIdleTimeout; + while (!ct.IsCancellationRequested) { - var line = await reader.ReadLineAsync(ct).ConfigureAwait(false); + string? line; + if (idleTimeout is { } idle && idle > TimeSpan.Zero) + { + // Bound each read: a half-open connection (server/network died without FIN) would otherwise + // block ReadLineAsync forever — HttpClient.Timeout does not cover the streamed body. On idle, + // throw so RunAsync treats it as a dead connection and reconnects with backoff. + using var readCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + readCts.CancelAfter(idle); + try + { + line = await reader.ReadLineAsync(readCts.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (!ct.IsCancellationRequested) + { + throw new TimeoutException( + $"SSE read idle for {idle.TotalSeconds:F0}s; treating the connection as dead and reconnecting."); + } + } + else + { + line = await reader.ReadLineAsync(ct).ConfigureAwait(false); + } if (line is null) { diff --git a/src/tests/Cocoar.Configuration.Providers.Tests/Http/HttpProviderSmokeTests.cs b/src/tests/Cocoar.Configuration.Providers.Tests/Http/HttpProviderSmokeTests.cs index d3c43e6..9cecdf7 100644 --- a/src/tests/Cocoar.Configuration.Providers.Tests/Http/HttpProviderSmokeTests.cs +++ b/src/tests/Cocoar.Configuration.Providers.Tests/Http/HttpProviderSmokeTests.cs @@ -39,13 +39,7 @@ public async Task FetchConfigurationAsync_ReadsJson_FromHandler() public async Task ConfigManager_Recompute_OnChange_Required() { // two responses: first value=1 then value=2 - var queue = new Queue(new[] - { - new HttpResponseMessage(HttpStatusCode.OK) - { Content = new StringContent("{ \"Value\": 1 }", Encoding.UTF8, "application/json") }, - new HttpResponseMessage(HttpStatusCode.OK) - { Content = new StringContent("{ \"Value\": 2 }", Encoding.UTF8, "application/json") } - }); + var queue = new Queue(new[] { "{ \"Value\": 1 }", "{ \"Value\": 2 }" }); var handler = new QueueHandler(queue); var services = new ServiceCollection(); @@ -165,23 +159,24 @@ private static HttpResponseMessage Clone(HttpResponseMessage resp) private sealed class QueueHandler : HttpMessageHandler { - private readonly Queue _queue; - private HttpResponseMessage? _last; - public QueueHandler(Queue queue) => _queue = queue; + private readonly Queue _queue; + private string _lastJson = "{ }"; + public QueueHandler(Queue queue) => _queue = queue; protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { if (_queue.Count > 0) { - _last = _queue.Dequeue(); - return Task.FromResult(_last); + _lastJson = _queue.Dequeue(); } - // Return last known response to keep config steady - var fallback = _last ?? new HttpResponseMessage(HttpStatusCode.OK) - { Content = new StringContent("{ }", Encoding.UTF8, "application/json") }; - return Task.FromResult(fallback); + // Fresh response per call — real handlers never reuse a (disposable) HttpResponseMessage instance, + // and the provider disposes each response after reading it. + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(_lastJson, Encoding.UTF8, "application/json") + }); } } } diff --git a/src/tests/Cocoar.Configuration.Providers.Tests/Http/SseObservableTests.cs b/src/tests/Cocoar.Configuration.Providers.Tests/Http/SseObservableTests.cs new file mode 100644 index 0000000..f409d47 --- /dev/null +++ b/src/tests/Cocoar.Configuration.Providers.Tests/Http/SseObservableTests.cs @@ -0,0 +1,101 @@ +using System.Net; +using System.Text; +using Cocoar.Configuration.Http; +using Xunit; + +namespace Cocoar.Configuration.Providers.Tests.Http; + +/// +/// Tests for the SSE (Server-Sent Events) change path of , including the +/// read-idle timeout that reconnects a half-open (hung) connection. +/// +public class SseObservableTests +{ + private static HttpProviderQueryOptions Query => new("https://example.com/sse"); + + [Fact] + [Trait("Type", "Integration")] + [Trait("Provider", "HttpProvider")] + public async Task Sse_emits_bytes_from_a_data_line() + { + // A stream with a single SSE data line, then end-of-stream. + var handler = new SseHandler(() => + new MemoryStream(Encoding.UTF8.GetBytes("data: {\"Value\":7}\n\n"))); + + var provider = new HttpProvider(new HttpProviderOptions(serverSentEvents: true, handler: handler)); + + var got = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var sub = provider.ChangesAsBytes(Query) + .Subscribe(bytes => got.TrySetResult(Encoding.UTF8.GetString(bytes))); + + var finished = await Task.WhenAny(got.Task, Task.Delay(3000)) == got.Task; + + Assert.True(finished, "SSE should emit the data-line payload"); + Assert.Equal("{\"Value\":7}", await got.Task); + } + + [Fact] + [Trait("Type", "Integration")] + [Trait("Provider", "HttpProvider")] + public async Task Sse_read_idle_timeout_reconnects_a_hung_stream() + { + // A stream that sends headers then blocks forever — a half-open connection. Without the idle + // timeout this would hang; with it, each read times out and RunAsync reconnects (new connection). + var handler = new SseHandler(() => new BlockingStream()); + + var provider = new HttpProvider(new HttpProviderOptions( + serverSentEvents: true, + handler: handler, + sseReadIdleTimeout: TimeSpan.FromMilliseconds(150))); + + using var sub = provider.ChangesAsBytes(Query).Subscribe(_ => { }); + + // First connect is immediate; idle timeout (150ms) + backoff (1s) → a reconnect within ~2.5s. + var sw = System.Diagnostics.Stopwatch.StartNew(); + while (sw.Elapsed < TimeSpan.FromSeconds(3) && Volatile.Read(ref handler.Connections) < 2) + { + await Task.Delay(50); + } + + Assert.True(Volatile.Read(ref handler.Connections) >= 2, + $"hung SSE stream should reconnect; connections={handler.Connections}"); + } + + private sealed class SseHandler(Func streamFactory) : HttpMessageHandler + { + public int Connections; + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken ct) + { + Interlocked.Increment(ref Connections); + var resp = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StreamContent(streamFactory()) + }; + resp.Content.Headers.ContentType = new("text/event-stream"); + return Task.FromResult(resp); + } + } + + /// A readable stream whose reads block until cancelled — simulates a half-open connection. + private sealed class BlockingStream : Stream + { + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + public override long Position { get => 0; set => throw new NotSupportedException(); } + public override void Flush() { } + public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + + public override async ValueTask ReadAsync(Memory buffer, CancellationToken ct = default) + { + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + await using var reg = ct.Register(() => tcs.TrySetCanceled(ct)); + return await tcs.Task.ConfigureAwait(false); // never completes except via cancellation + } + } +} From a8d14c8951a406447df9c25ee001a15867862080 Mon Sep 17 00:00:00 2001 From: Bernhard Windisch Date: Tue, 2 Jun 2026 16:29:43 +0200 Subject: [PATCH 03/10] chore(msadapter): remove dead MicrosoftConfigurationSource* code After v6.0.0 removed the FromMicrosoftSource() public API, the MicrosoftConfigurationSourceProvider family (provider + 3 option types) had no production references. Delete the 4 dead files, the Legacy_* test region, and the phantom '#pragma warning disable CS0618' (CreateRule was never [Obsolete]). The live FromIConfiguration path and its tests are untouched. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../MicrosoftConfigurationSourceProvider.cs | 180 -------------- ...osoftConfigurationSourceProviderOptions.cs | 22 -- ...ConfigurationSourceProviderQueryOptions.cs | 10 - ...MicrosoftConfigurationSourceRuleOptions.cs | 30 --- .../MicrosoftAdapterBattleTests.cs | 228 ------------------ .../MicrosoftAdapterSmokeTests.cs | 19 -- 6 files changed, 489 deletions(-) delete mode 100644 src/Cocoar.Configuration.MicrosoftAdapter/MicrosoftConfigurationSourceProvider.cs delete mode 100644 src/Cocoar.Configuration.MicrosoftAdapter/MicrosoftConfigurationSourceProviderOptions.cs delete mode 100644 src/Cocoar.Configuration.MicrosoftAdapter/MicrosoftConfigurationSourceProviderQueryOptions.cs delete mode 100644 src/Cocoar.Configuration.MicrosoftAdapter/MicrosoftConfigurationSourceRuleOptions.cs diff --git a/src/Cocoar.Configuration.MicrosoftAdapter/MicrosoftConfigurationSourceProvider.cs b/src/Cocoar.Configuration.MicrosoftAdapter/MicrosoftConfigurationSourceProvider.cs deleted file mode 100644 index ef03ebe..0000000 --- a/src/Cocoar.Configuration.MicrosoftAdapter/MicrosoftConfigurationSourceProvider.cs +++ /dev/null @@ -1,180 +0,0 @@ -using System.Text.Json; -using Cocoar.Configuration.Core; -using Cocoar.Configuration.Providers.Abstractions; -using Microsoft.Extensions.Configuration; - -namespace Cocoar.Configuration.MicrosoftAdapter; - -public sealed class MicrosoftConfigurationSourceProvider( - MicrosoftConfigurationSourceProviderOptions options -) : ConfigurationProvider(options) -{ - private IConfigurationProvider BuildProvider() - { - var builder = new ConfigurationBuilder(); - if (!string.IsNullOrWhiteSpace(ProviderOptions.BasePath)) - { - builder.SetBasePath(ProviderOptions.BasePath); - } - - builder.Add(ProviderOptions.Source); - return builder.Build().Providers.Last(); - } - - public override Task FetchConfigurationBytesAsync(MicrosoftConfigurationSourceProviderQueryOptions query, - CancellationToken ct = default) - { - var provider = BuildProvider(); - var root = new ConfigurationRoot(new[] { provider }); - var dict = Flatten(root, query.ConfigurationPrefix); - var bytes = DictToJsonBytes(dict); - return Task.FromResult(bytes); - } - - public override IObservable ChangesAsBytes(MicrosoftConfigurationSourceProviderQueryOptions query) - { - return new ChangeTokenObservable(this, query); - } - - private static Dictionary Flatten(IConfigurationRoot root, string? ConfigurationPrefix) - { - var dict = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var kv in root.AsEnumerable(makePathsRelative: false)) - { - if (kv.Value is null || string.IsNullOrWhiteSpace(kv.Key)) - { - continue; - } - - if (!string.IsNullOrWhiteSpace(ConfigurationPrefix)) - { - if (!kv.Key.StartsWith(ConfigurationPrefix + ":", StringComparison.OrdinalIgnoreCase) - && !kv.Key.Equals(ConfigurationPrefix, StringComparison.OrdinalIgnoreCase)) - { - continue; - } - - var rel = kv.Key.Equals(ConfigurationPrefix, StringComparison.OrdinalIgnoreCase) - ? string.Empty - : kv.Key.Substring(ConfigurationPrefix.Length + 1); - if (rel.Length == 0) - { - continue; - } - - dict[rel] = kv.Value; - } - else - { - dict[kv.Key] = kv.Value; - } - } - - return dict; - } - - private static byte[] DictToJsonBytes(Dictionary flat) - { - var root = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var (k, v) in flat) - { - var parts = k.Split(':'); - var cur = root; - for (var i = 0; i < parts.Length - 1; i++) - { - if (!cur.TryGetValue(parts[i], out var next) || next is not Dictionary nextDict) - { - nextDict = new(StringComparer.OrdinalIgnoreCase); - cur[parts[i]] = nextDict; - } - - cur = nextDict; - } - - cur[parts[^1]] = v; - } - - return JsonSerializer.SerializeToUtf8Bytes(root); - } - - /// - /// Helper method to create a Microsoft configuration source rule for testing purposes. - /// - public static Cocoar.Configuration.Rules.ConfigRule CreateRule( - Func optionsFactory, - bool required = false) - { - return Cocoar.Configuration.Rules.ConfigRule.Create( - cm => optionsFactory(cm).ToProviderOptions(), - cm => optionsFactory(cm).ToQueryOptions(), - typeof(T), - new Cocoar.Configuration.Rules.ConfigRuleOptions(Required: required, UseWhen: null) - ); - } - - /// - /// Wraps IChangeToken from a Microsoft configuration provider as an IObservable. - /// Re-registers the change token after each callback (IChangeToken is single-fire). - /// - private sealed class ChangeTokenObservable( - MicrosoftConfigurationSourceProvider owner, - MicrosoftConfigurationSourceProviderQueryOptions query) : IObservable - { - public IDisposable Subscribe(IObserver observer) - { - var state = new ChangeTokenState(owner, query, observer); - state.Register(); - return state; - } - - private sealed class ChangeTokenState : IDisposable - { - private readonly MicrosoftConfigurationSourceProvider _owner; - private readonly MicrosoftConfigurationSourceProviderQueryOptions _query; - private readonly IObserver _observer; - private readonly IConfigurationProvider _provider; - private IDisposable? _registration; - private int _disposed; - - public ChangeTokenState( - MicrosoftConfigurationSourceProvider owner, - MicrosoftConfigurationSourceProviderQueryOptions query, - IObserver observer) - { - _owner = owner; - _query = query; - _observer = observer; - _provider = owner.BuildProvider(); - } - - public void Register() - { - if (Volatile.Read(ref _disposed) != 0) return; - var token = _provider.GetReloadToken(); - _registration = token.RegisterChangeCallback(_ => OnChange(), null); - } - - private void OnChange() - { - if (Volatile.Read(ref _disposed) != 0) return; - - var root = new ConfigurationRoot(new[] { _provider }); - var dict = Flatten(root, _query.ConfigurationPrefix); - var bytes = DictToJsonBytes(dict); - try { _observer.OnNext(bytes); } catch { /* observer fault must not break re-registration */ } - - // IChangeToken is single-fire — re-register for the next change - Register(); - } - - public void Dispose() - { - if (Interlocked.Exchange(ref _disposed, 1) != 0) return; - _registration?.Dispose(); - } - } - } -} diff --git a/src/Cocoar.Configuration.MicrosoftAdapter/MicrosoftConfigurationSourceProviderOptions.cs b/src/Cocoar.Configuration.MicrosoftAdapter/MicrosoftConfigurationSourceProviderOptions.cs deleted file mode 100644 index 719d765..0000000 --- a/src/Cocoar.Configuration.MicrosoftAdapter/MicrosoftConfigurationSourceProviderOptions.cs +++ /dev/null @@ -1,22 +0,0 @@ -using Cocoar.Configuration.Providers.Abstractions; -using Microsoft.Extensions.Configuration; - -namespace Cocoar.Configuration.MicrosoftAdapter; - -public sealed class MicrosoftConfigurationSourceProviderOptions : IProviderConfiguration -{ - public IConfigurationSource Source { get; } - public string? BasePath { get; } - public string? Identity { get; } - - public MicrosoftConfigurationSourceProviderOptions(IConfigurationSource source, string? basePath = null, - string? identity = null) - { - Source = source; - BasePath = basePath; - Identity = identity; - } - - string IProviderConfiguration.GenerateProviderKey() - => $"{Source.GetType().FullName}|{BasePath}|{Identity}"; -} diff --git a/src/Cocoar.Configuration.MicrosoftAdapter/MicrosoftConfigurationSourceProviderQueryOptions.cs b/src/Cocoar.Configuration.MicrosoftAdapter/MicrosoftConfigurationSourceProviderQueryOptions.cs deleted file mode 100644 index 8a82892..0000000 --- a/src/Cocoar.Configuration.MicrosoftAdapter/MicrosoftConfigurationSourceProviderQueryOptions.cs +++ /dev/null @@ -1,10 +0,0 @@ -using Cocoar.Configuration.Providers.Abstractions; - -namespace Cocoar.Configuration.MicrosoftAdapter; - -public sealed class MicrosoftConfigurationSourceProviderQueryOptions( - string? configurationPrefix = null -) : IProviderQuery -{ - public string? ConfigurationPrefix { get; } = configurationPrefix; -} diff --git a/src/Cocoar.Configuration.MicrosoftAdapter/MicrosoftConfigurationSourceRuleOptions.cs b/src/Cocoar.Configuration.MicrosoftAdapter/MicrosoftConfigurationSourceRuleOptions.cs deleted file mode 100644 index 43ca35d..0000000 --- a/src/Cocoar.Configuration.MicrosoftAdapter/MicrosoftConfigurationSourceRuleOptions.cs +++ /dev/null @@ -1,30 +0,0 @@ -using Microsoft.Extensions.Configuration; - -namespace Cocoar.Configuration.MicrosoftAdapter; - -// Combined options for the Microsoft IConfigurationSource adapter (instance + query) -public sealed class MicrosoftConfigurationSourceRuleOptions -{ - public IConfigurationSource Source { get; } - public string? BasePath { get; } - public string? Identity { get; } - public string? ConfigurationPrefix { get; } - - public MicrosoftConfigurationSourceRuleOptions( - IConfigurationSource source, - string? basePath = null, - string? identity = null, - string? configurationPrefix = null) - { - Source = source ?? throw new ArgumentNullException(nameof(source)); - BasePath = basePath; - Identity = identity; - ConfigurationPrefix = configurationPrefix; - } - - public MicrosoftConfigurationSourceProviderOptions ToProviderOptions() - => new(Source, BasePath, Identity); - - public MicrosoftConfigurationSourceProviderQueryOptions ToQueryOptions() - => new(ConfigurationPrefix); -} diff --git a/src/tests/Cocoar.Configuration.Providers.Tests/MicrosoftAdapter/MicrosoftAdapterBattleTests.cs b/src/tests/Cocoar.Configuration.Providers.Tests/MicrosoftAdapter/MicrosoftAdapterBattleTests.cs index d5219d7..d218a81 100644 --- a/src/tests/Cocoar.Configuration.Providers.Tests/MicrosoftAdapter/MicrosoftAdapterBattleTests.cs +++ b/src/tests/Cocoar.Configuration.Providers.Tests/MicrosoftAdapter/MicrosoftAdapterBattleTests.cs @@ -21,234 +21,6 @@ public void Dispose() } } - #region Legacy MicrosoftConfigurationSourceProvider tests - - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "MicrosoftAdapter")] - public async Task Legacy_FetchConfigurationAsync_ReadsFromInMemoryConfig() - { - - var data = new Dictionary - { - ["App:Name"] = "TestApp", - ["App:Version"] = "1.0.0", - ["Database:ConnectionString"] = "Server=localhost", - ["Database:Timeout"] = "30" - }; - var configSource = new MemoryConfigurationSource { InitialData = data }; - - var provider = new MicrosoftConfigurationSourceProvider( - new(configSource)); - // Note: MicrosoftConfigurationSourceProvider doesn't implement IDisposable - - - var result = await provider.FetchConfigurationBytesAsync( - new MicrosoftConfigurationSourceProviderQueryOptions()); - - - Assert.Equal(JsonValueKind.Object, result.ToJsonElement().ValueKind); - - var app = result.ToJsonElement().GetProperty("App"); - Assert.Equal("TestApp", app.GetProperty("Name").GetString()); - Assert.Equal("1.0.0", app.GetProperty("Version").GetString()); - - var db = result.ToJsonElement().GetProperty("Database"); - Assert.Equal("Server=localhost", db.GetProperty("ConnectionString").GetString()); - Assert.Equal("30", db.GetProperty("Timeout").GetString()); - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "MicrosoftAdapter")] - public async Task Legacy_FetchConfigurationAsync_FiltersWithPrefix() - { - - var data = new Dictionary - { - ["App:Name"] = "TestApp", - ["Database:ConnectionString"] = "Server=localhost", - ["Logging:Level"] = "Debug", - ["Logging:Providers:Console"] = "true" - }; - var configSource = new MemoryConfigurationSource { InitialData = data }; - - var provider = new MicrosoftConfigurationSourceProvider( - new(configSource)); - - - var result = await provider.FetchConfigurationBytesAsync( - new MicrosoftConfigurationSourceProviderQueryOptions("Logging")); - - - Assert.Equal(JsonValueKind.Object, result.ToJsonElement().ValueKind); - Assert.Equal("Debug", result.ToJsonElement().GetProperty("Level").GetString()); - - var providers = result.ToJsonElement().GetProperty("Providers"); - Assert.Equal("true", providers.GetProperty("Console").GetString()); - - - Assert.False(result.ToJsonElement().TryGetProperty("App", out _)); - Assert.False(result.ToJsonElement().TryGetProperty("Database", out _)); - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "MicrosoftAdapter")] - public async Task Legacy_FetchConfigurationAsync_HandlesEmptyConfiguration() - { - - var configSource = new MemoryConfigurationSource { InitialData = new Dictionary() }; - - var provider = new MicrosoftConfigurationSourceProvider( - new(configSource)); - - - var result = await provider.FetchConfigurationBytesAsync( - new MicrosoftConfigurationSourceProviderQueryOptions()); - - - Assert.Equal(JsonValueKind.Object, result.ToJsonElement().ValueKind); - Assert.Equal("{}", result.ToJsonElement().GetRawText()); - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "MicrosoftAdapter")] - public async Task Legacy_FetchConfigurationAsync_HandlesNonExistentPrefix() - { - - var data = new Dictionary - { - ["App:Name"] = "TestApp" - }; - var configSource = new MemoryConfigurationSource { InitialData = data }; - - var provider = new MicrosoftConfigurationSourceProvider( - new(configSource)); - - - var result = await provider.FetchConfigurationBytesAsync( - new MicrosoftConfigurationSourceProviderQueryOptions("NonExistent")); - - - Assert.Equal(JsonValueKind.Object, result.ToJsonElement().ValueKind); - Assert.Equal("{}", result.ToJsonElement().GetRawText()); - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "MicrosoftAdapter")] - public async Task Legacy_FetchConfigurationAsync_HandlesComplexNesting() - { - - var data = new Dictionary - { - ["Services:Database:Primary:Host"] = "db1.example.com", - ["Services:Database:Primary:Port"] = "5432", - ["Services:Database:Secondary:Host"] = "db2.example.com", - ["Services:Cache:Redis:Endpoints:0"] = "redis1.example.com", - ["Services:Cache:Redis:Endpoints:1"] = "redis2.example.com" - }; - var configSource = new MemoryConfigurationSource { InitialData = data }; - - var provider = new MicrosoftConfigurationSourceProvider( - new(configSource)); - - - var result = await provider.FetchConfigurationBytesAsync( - new MicrosoftConfigurationSourceProviderQueryOptions()); - - - var services = result.ToJsonElement().GetProperty("Services"); - - var primaryDb = services.GetProperty("Database").GetProperty("Primary"); - Assert.Equal("db1.example.com", primaryDb.GetProperty("Host").GetString()); - Assert.Equal("5432", primaryDb.GetProperty("Port").GetString()); - - var secondaryDb = services.GetProperty("Database").GetProperty("Secondary"); - Assert.Equal("db2.example.com", secondaryDb.GetProperty("Host").GetString()); - - var redisEndpoints = services.GetProperty("Cache").GetProperty("Redis").GetProperty("Endpoints"); - Assert.Equal("redis1.example.com", redisEndpoints.GetProperty("0").GetString()); - Assert.Equal("redis2.example.com", redisEndpoints.GetProperty("1").GetString()); - } - - [Fact] - [Trait("Type", "Integration")] - [Trait("Provider", "MicrosoftAdapter")] - public void Legacy_ConfigManager_IntegratesWithMicrosoftConfig() - { - - var data = new Dictionary - { - ["App:Name"] = "IntegrationTest", - ["App:Features:EnableCache"] = "true", - ["App:Features:MaxConnections"] = "100" - }; - var configSource = new MemoryConfigurationSource { InitialData = data }; - -#pragma warning disable CS0618 // Obsolete - var rule = MicrosoftConfigurationSourceProvider.CreateRule(_ => new( - configSource, - configurationPrefix: "App")); -#pragma warning restore CS0618 - - using var manager = ConfigManager.Create(c => c.UseConfiguration(new[] { rule })); - - - var config = manager.GetConfig(); - - - Assert.NotNull(config); - Assert.Equal("IntegrationTest", config.Name); - Assert.NotNull(config.Features); - Assert.True(config.Features.EnableCache); - Assert.Equal(100, config.Features.MaxConnections); - - - Assert.Equal(Health.HealthStatus.Healthy, manager.HealthStatus); - } - - [Fact] - [Trait("Type", "Unit")] - [Trait("Provider", "MicrosoftAdapter")] - public async Task Legacy_FetchConfigurationAsync_HandlesCaseInsensitiveKeys() - { - - var data = new Dictionary - { - ["app:name"] = "LowerCase", - ["APP:VERSION"] = "UpperCase", - ["App:Environment"] = "MixedCase" - }; - var configSource = new MemoryConfigurationSource { InitialData = data }; - - var provider = new MicrosoftConfigurationSourceProvider( - new(configSource)); - - - var result = await provider.FetchConfigurationBytesAsync( - new MicrosoftConfigurationSourceProviderQueryOptions()); - - - // Let's first check what the actual structure looks like - var jsonString = result.ToJsonElement().GetRawText(); - Assert.NotEmpty(jsonString); - - // The keys might be normalized, so let's check what's actually available - Assert.True(result.ToJsonElement().TryGetProperty("app", out var app) || result.ToJsonElement().TryGetProperty("App", out app) || result.ToJsonElement().TryGetProperty("APP", out app)); - - // Check for properties within the app section using case-insensitive search - var hasName = app.TryGetProperty("name", out var nameElement) || - app.TryGetProperty("Name", out nameElement) || - app.TryGetProperty("NAME", out nameElement); - Assert.True(hasName); - Assert.Equal("LowerCase", nameElement.GetString()); - } - - #endregion - #region New MicrosoftConfigurationProvider tests (IConfiguration-based) [Fact] diff --git a/src/tests/Cocoar.Configuration.Providers.Tests/MicrosoftAdapter/MicrosoftAdapterSmokeTests.cs b/src/tests/Cocoar.Configuration.Providers.Tests/MicrosoftAdapter/MicrosoftAdapterSmokeTests.cs index 748d9fd..b0e60b8 100644 --- a/src/tests/Cocoar.Configuration.Providers.Tests/MicrosoftAdapter/MicrosoftAdapterSmokeTests.cs +++ b/src/tests/Cocoar.Configuration.Providers.Tests/MicrosoftAdapter/MicrosoftAdapterSmokeTests.cs @@ -12,25 +12,6 @@ public class MicrosoftAdapterSmokeTests #if INCLUDE_MICROSOFT_ADAPTER_TESTS private sealed class AppConfig { public string? Value { get; set; } } - [Fact] - [Trait("Type", "Unit")] - public void Legacy_Adapter_Loads_From_MemoryConfig() - { - var dict = new Dictionary - { - ["App:Value"] = "42" - }; - var configSource = new Microsoft.Extensions.Configuration.Memory.MemoryConfigurationSource { InitialData = dict }; - -#pragma warning disable CS0618 // Obsolete - var rule = MicrosoftConfigurationSourceProvider.CreateRule(_ => new(configSource, configurationPrefix: "App")); -#pragma warning restore CS0618 - using var manager = ConfigManager.Create(c => c.UseConfiguration(new[]{rule})); - var config = manager.GetConfig(); - Assert.Equal("42", config!.Value); - Assert.Equal(Health.HealthStatus.Healthy, manager.HealthStatus); - } - [Fact] [Trait("Type", "Unit")] public void Adapter_Loads_From_IConfiguration() From 46f5dd629cea347eacf92d56d824920f30684747 Mon Sep 17 00:00:00 2001 From: Bernhard Windisch Date: Tue, 2 Jun 2026 16:33:18 +0200 Subject: [PATCH 04/10] docs(providers): document the provider contract invariants on the base types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spell out on ConfigurationProvider / IProviderConfiguration the invariants that previously lived only in ADRs/memory: empty-{}-never-null; fetch MAY throw (Required rollback vs Optional degrade) while a faulting change-stream is recovered by the engine (unsubscribe + recompute), so providers may emit {} or OnError but must not hang; don't retain secret bytes; implement IDisposable when holding resources; an equal GenerateProviderKey shares the instance so it must be thread-safe, return null for non-keyable/identity options. Resolves the ADR-003 'inconsistency' finding as documentation: the engine's graceful OnError handling makes File's emit-{} and Observable's OnError equivalent — no behavior change needed. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Abstractions/ConfigurationProvider.cs | 38 +++++++++++++++++-- .../Abstractions/IProviderConfiguration.cs | 14 +++++-- 2 files changed, 45 insertions(+), 7 deletions(-) diff --git a/src/Cocoar.Configuration/Providers/Abstractions/ConfigurationProvider.cs b/src/Cocoar.Configuration/Providers/Abstractions/ConfigurationProvider.cs index 015aebb..a93aa1b 100644 --- a/src/Cocoar.Configuration/Providers/Abstractions/ConfigurationProvider.cs +++ b/src/Cocoar.Configuration/Providers/Abstractions/ConfigurationProvider.cs @@ -3,17 +3,47 @@ namespace Cocoar.Configuration.Providers.Abstractions; +/// +/// Base class for configuration providers. The currency is always raw UTF-8 JSON byte[] (never strings) +/// so sensitive payloads can be zeroed by consumers. +/// Provider contract — invariants implementations must honor: +/// +/// Empty, never null. Both methods deal in a JSON value; "no data" is an empty object +/// ({}), never a byte[] (ADR-003). The merge layer treats {} as an +/// invisible layer. +/// Don't retain payload bytes. Return fresh arrays a caller may zero; never cache secret bytes on +/// the provider. +/// Disposal is opt-in but expected. If the provider owns resources (file watchers, timers, +/// subscriptions, a ), implement — +/// the provider registry disposes providers that implement it. +/// Shared instances must be thread-safe. Providers with an equal +/// are shared across rules, so a shared instance may +/// receive concurrent / calls. +/// +/// public abstract class ConfigurationProvider { /// - /// Fetches configuration as raw UTF-8 JSON bytes, avoiding string allocations. - /// This is more secure for sensitive data as bytes can be zeroed after use. + /// Fetches the current configuration snapshot as raw UTF-8 JSON bytes (no string allocations, so secrets can + /// be zeroed by the consumer). + /// + /// Return an empty object ("{}"u8) when no value is available — never . This + /// method may throw to signal a hard failure: the recompute then rolls back for a Required rule + /// (health → Unhealthy, startup exception) or degrades for an optional one — the throw is how the two are + /// distinguished, so don't swallow a genuine failure into {} here. Honor . + /// /// public abstract Task FetchConfigurationBytesAsync(IProviderQuery query, CancellationToken ct = default); /// - /// Observes configuration changes as raw UTF-8 JSON bytes, avoiding string allocations. - /// This is more secure for sensitive data as bytes can be zeroed after use. + /// Observes configuration changes as raw UTF-8 JSON bytes. Emit the fresh snapshot on each change. + /// + /// The engine handles a faulting stream gracefully: an causes it to + /// unsubscribe and schedule a recompute (which re-subscribes and re-fetches). So on a transient failure a + /// provider may either emit {} (degrade in-stream, like the file provider) or call OnError — + /// both recover; just never let the stream hang. The subscription returned to a subscriber is the teardown + /// handle (there is no CancellationToken on this method) — dispose it to stop observing. + /// /// public abstract IObservable ChangesAsBytes(IProviderQuery query); } diff --git a/src/Cocoar.Configuration/Providers/Abstractions/IProviderConfiguration.cs b/src/Cocoar.Configuration/Providers/Abstractions/IProviderConfiguration.cs index 434c123..a70ccbe 100644 --- a/src/Cocoar.Configuration/Providers/Abstractions/IProviderConfiguration.cs +++ b/src/Cocoar.Configuration/Providers/Abstractions/IProviderConfiguration.cs @@ -11,9 +11,17 @@ public interface IProviderConfiguration WriteIndented = false }; - /// Generates a provider key for instance sharing. - /// Return null to indicate this provider should never be reused/shared. - /// Return the same key to share provider instances with the same key. + /// + /// Generates a key used to share provider instances: rules whose options produce the same key share + /// one provider instance; a key opts out of sharing (a fresh instance per rule). + /// + /// The default serializes these options to JSON, so two rules with value-equal options share an instance — + /// which means that shared instance MUST be thread-safe. Return when the options carry + /// state that JSON can't faithfully key on or that must not be shared — e.g. a live IObservable, an + /// externally-owned client/factory, or anything with reference identity (see ObservableProviderOptions + /// and the [JsonIgnore] client-factory cases in the HTTP and writable-store options). + /// + /// string? GenerateProviderKey() { return JsonSerializer.Serialize(this, GetType(), ProviderKeyOptions); From c05c76113025cfc99ae4b94ca7449cddecd7e0ad Mon Sep 17 00:00:00 2001 From: Bernhard Windisch Date: Tue, 2 Jun 2026 16:45:23 +0200 Subject: [PATCH 05/10] polish(providers): parameterless FromCommandLine, EOL consistency, File Dispose hardening #9: add parameterless FromCommandLine() (default -- switch prefix, no key filter) so the documented 'simple usage' is a real overload; document the '--key -value' is-a-flag footgun (XML remark + VitePress warning); delete the source-tree CommandLineProvider/README.md (VitePress is the docs SSOT). #10: EnvironmentVariableProviderOptions.GenerateProviderKey returns string? (matches the interface / CommandLine). #11: FileSourceProvider.Dispose is race-safe (Interlocked) and guards _cts.Cancel() so a faulting cancellation callback can't escape Dispose. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../CommandLineArgumentRulesExtensions.cs | 18 ++ .../Providers/CommandLineProvider/README.md | 272 ------------------ .../EnvironmentVariableOptions.cs | 2 +- .../FileSourceProvider/FileSourceProvider.cs | 22 +- website/guide/providers/command-line.md | 6 + 5 files changed, 39 insertions(+), 281 deletions(-) delete mode 100644 src/Cocoar.Configuration/Providers/CommandLineProvider/README.md diff --git a/src/Cocoar.Configuration/Providers/CommandLineProvider/CommandLineArgumentRulesExtensions.cs b/src/Cocoar.Configuration/Providers/CommandLineProvider/CommandLineArgumentRulesExtensions.cs index ac2b3f9..2ee4edd 100644 --- a/src/Cocoar.Configuration/Providers/CommandLineProvider/CommandLineArgumentRulesExtensions.cs +++ b/src/Cocoar.Configuration/Providers/CommandLineProvider/CommandLineArgumentRulesExtensions.cs @@ -5,6 +5,24 @@ namespace Cocoar.Configuration.Providers; public static class CommandLineArgumentRulesExtensions { + /// + /// Parses command-line arguments using the default -- switch prefix and no key-prefix filter — + /// every --switch maps into the configuration. + /// + /// + /// A value that itself begins with a switch prefix (e.g. --port -5) is parsed as a boolean flag, + /// not a value; write --port=-5 for such values. + /// + public static + ProviderRuleBuilder FromCommandLine(this TypedProviderBuilder builder) + where T : class + => new( + cm => new(), + cm => new(null, null, null), + typeof(T) + ); + public static ProviderRuleBuilder FromCommandLine(this TypedProviderBuilder builder, string prefix, string[]? switchPrefixes = null) diff --git a/src/Cocoar.Configuration/Providers/CommandLineProvider/README.md b/src/Cocoar.Configuration/Providers/CommandLineProvider/README.md deleted file mode 100644 index 59735bd..0000000 --- a/src/Cocoar.Configuration/Providers/CommandLineProvider/README.md +++ /dev/null @@ -1,272 +0,0 @@ -# CommandLine Provider - -The CommandLine provider allows you to load configuration from command-line arguments with flexible, configurable argument parsing. - -## Features - -- **Flexible switch prefixes**: Support any prefix style (`--`, `-`, `/`, `@`, `#`, `%`, etc.) - even multiple at once! -- **Multiple formats**: Supports `--key=value`, `--key value`, and boolean flags -- **Nested configuration**: Use `:` or `__` for hierarchical keys (e.g., `--database:host=localhost`) -- **Prefix filtering**: Filter arguments by prefix to map to specific configuration types -- **Automatic fallback**: Uses `Environment.GetCommandLineArgs()` when args not explicitly provided - -## Basic Usage - -### Simple usage (default `--` prefix) - -```csharp -builder.Services.AddCocoarConfiguration(c => c.UseConfiguration(rule => [ - rule.For().FromCommandLine() -])); -``` - -Command line: -```bash -dotnet run --host=localhost --port=8080 --verbose -``` - -Maps to: -```csharp -public class AppConfig -{ - public string Host { get; set; } // "localhost" - public int Port { get; set; } // 8080 - public bool Verbose { get; set; } // true -} -``` - -### Custom switch prefixes - -Use any prefix style you prefer: - -```csharp -// Single dash (Unix-style) -rule.For().FromCommandLine(["-"]) - -// Forward slash (Windows-style) -rule.For().FromCommandLine(["/"]) - -// Custom prefixes for semantic clarity -rule.For().FromCommandLine(["@", "#"]) -``` - -**...or literally any string you want** 😏 - -### Multiple switch prefixes - -Accept multiple prefix styles simultaneously: - -```csharp -rule.For().FromCommandLine(["--", "-", "/"]) -``` - -Command line can now mix styles: -```bash -dotnet run --host=localhost -port=8080 /verbose -``` - -**Note:** Prefixes are matched longest-first, so `--` is checked before `-` to avoid ambiguity. - -## Advanced Usage - -### Nested Configuration - -Use `:` or `__` to create hierarchical configuration: - -```bash -dotnet run --database:host=localhost --database:port=5432 -``` - -Maps to: -```csharp -public class AppConfig -{ - public DatabaseConfig Database { get; set; } -} - -public class DatabaseConfig -{ - public string Host { get; set; } // "localhost" - public int Port { get; set; } // 5432 -} -``` - -### Using Prefix to Map Multiple Types - -You can use prefixes to map different command-line arguments to different configuration types: - -```csharp -builder.Services.AddCocoarConfiguration(c => c.UseConfiguration(rule => [ - rule.For().FromCommandLine("app_"), - rule.For().FromCommandLine("db_") -])); -``` - -Command line: -```bash -dotnet run --app_host=localhost --db_connectionstring="Server=localhost" -``` - -This maps: -- `--app_host=localhost` → `AppConfig.Host` (prefix stripped, becomes `host`) -- `--db_connectionstring=...` → `DatabaseConfig.ConnectionString` (prefix stripped, becomes `connectionstring`) - -### Combining Prefix Filtering and Custom Switch Prefixes - -Mix semantic prefixes with custom switch styles: - -```csharp -builder.Services.AddCocoarConfiguration(c => c.UseConfiguration(rule => [ - rule.For().FromCommandLine("target_", ["@"]), - rule.For().FromCommandLine("issue_", ["#"]) -])); -``` - -Command line: -```bash -invoke.exe @target_host=10.10.10.10 #issue_id=123 -``` - -### Dynamic Configuration with Config-Aware Rules - -```csharp -builder.Services.AddCocoarConfiguration(c => c.UseConfiguration(rule => [ - rule.For().FromFile("tenant.json"), - - rule.For().FromCommandLine(accessor => - { - var tenant = accessor.GetConfig()!; - return new CommandLineRuleOptions - { - Prefix = $"{tenant.Name}_", - SwitchPrefixes = ["--", "-"] - }; - }) -])); -``` - -## Argument Format Support - -The provider supports multiple argument formats: - -| Format | Example | Result | -|--------|---------|--------| -| `--key=value` | `--host=localhost` | `{ "host": "localhost" }` | -| `--key value` | `--host localhost` | `{ "host": "localhost" }` | -| `--flag` | `--verbose` | `{ "verbose": "true" }` | -| `--nested:key` | `--db:host=localhost` | `{ "db": { "host": "localhost" } }` | -| `--nested__key` | `--db__host=localhost` | `{ "db": { "host": "localhost" } }` | - -**Custom prefixes:** All formats work with any configured switch prefix (`-`, `/`, `@`, etc.) - -## Configuration Options - -### Simple API - -```csharp -// Default (-- prefix, no filtering) -.FromCommandLine() - -// With prefix filtering only -.FromCommandLine("app_") - -// With custom switch prefix -.FromCommandLine(["-"]) - -// With multiple switch prefixes -.FromCommandLine(["--", "-", "/"]) - -// Prefix filtering + custom switches -.FromCommandLine("app_", ["@", "#"]) -``` - -### Factory API (for testing/advanced scenarios) - -```csharp -.FromCommandLine(cm => new CommandLineRuleOptions -{ - Args = testArgs, // For testing; defaults to Environment.GetCommandLineArgs() - SwitchPrefixes = ["@", "#"], // Custom switch prefixes; defaults to ["--"] - Prefix = "app_" // Prefix filter; defaults to null (no filtering) -}) -``` - -## Type Conversion - -The ConfigurationManager automatically converts string values to the target property types: - -```bash -dotnet run --port=8080 --timeout=30.5 --enabled=true -``` - -```csharp -public class AppConfig -{ - public int Port { get; set; } // Converted to int: 8080 - public double Timeout { get; set; } // Converted to double: 30.5 - public bool Enabled { get; set; } // Converted to bool: true -} -``` - -## Layering with Other Providers - -Command-line arguments are typically used as the highest-priority layer to override file and environment-based configuration: - -```csharp -builder.Services.AddCocoarConfiguration(c => c.UseConfiguration(rule => [ - rule.For().FromFile("appsettings.json"), // Base - rule.For().FromEnvironment("APP_"), // Override - rule.For().FromCommandLine() // Final override -])); -``` - -Command line: -```bash -dotnet run --port=9000 -``` - -This overrides the port from both the file and environment variables. - -## Creative Use Cases - -### Semantic Prefixes for Self-Documenting CLIs - -```csharp -rule.For().FromCommandLine(["@"]), -rule.For().FromCommandLine(["#"]), -rule.For().FromCommandLine(["%"]) -``` - -```bash -invoke.exe @host=10.10.10.10 #ticket=456 %env=prod -``` - -### Mixed Unix/Windows Style - -Accept both Unix and Windows conventions: - -```csharp -rule.For().FromCommandLine(["--", "/"]) -``` - -```bash -dotnet run --host=localhost /port=8080 -``` - -## Limitations - -- **No reactive updates**: Command-line arguments are static; they don't change during application lifetime -- **Basic parsing only**: No support for subcommands, argument validation, or complex parsing rules -- **String-based**: All values are initially strings and must be convertible to target property types - -## Use Cases - -- **Development overrides**: Quickly override configuration during development -- **Container/deployment**: Pass environment-specific values at runtime (e.g., `docker run ... --port=8080`) -- **Testing**: Inject test configuration without modifying environment or files -- **Self-documenting CLIs**: Use semantic prefixes (`@host`, `#issue`) for clarity - -## See Also - -- [Environment Variable Provider](../EnvironmentVariableProvider/README.md) -- [File Source Provider](../FileSourceProvider/README.md) diff --git a/src/Cocoar.Configuration/Providers/EnvironmentVariableProvider/EnvironmentVariableOptions.cs b/src/Cocoar.Configuration/Providers/EnvironmentVariableProvider/EnvironmentVariableOptions.cs index e4ab597..8a9e23f 100644 --- a/src/Cocoar.Configuration/Providers/EnvironmentVariableProvider/EnvironmentVariableOptions.cs +++ b/src/Cocoar.Configuration/Providers/EnvironmentVariableProvider/EnvironmentVariableOptions.cs @@ -8,6 +8,6 @@ public record EnvironmentVariableProviderQueryOptions(string? EnvironmentPrefix public record EnvironmentVariableProviderOptions() : IProviderConfiguration { - public string GenerateProviderKey() => "Environment:Global"; + public string? GenerateProviderKey() => "Environment:Global"; } diff --git a/src/Cocoar.Configuration/Providers/FileSourceProvider/FileSourceProvider.cs b/src/Cocoar.Configuration/Providers/FileSourceProvider/FileSourceProvider.cs index a0b2b63..cb7447d 100644 --- a/src/Cocoar.Configuration/Providers/FileSourceProvider/FileSourceProvider.cs +++ b/src/Cocoar.Configuration/Providers/FileSourceProvider/FileSourceProvider.cs @@ -12,7 +12,7 @@ public sealed class FileSourceProvider : ConfigurationProvider _changeSubject = new(); private readonly ResilientFileSystemMonitor _monitor; private readonly CancellationTokenSource _cts = new(); - private bool _disposed; + private int _disposed; public FileSourceProvider(FileSourceProviderOptions options) : base(options) { @@ -142,17 +142,23 @@ private byte[] LoadFileBytes(string filename) public void Dispose() { - if (_disposed) + if (Interlocked.Exchange(ref _disposed, 1) != 0) { - return; + return; // already disposed (race-safe) } - _disposed = true; + try + { + _cts.Cancel(); + } + catch (Exception) + { + // A cancellation callback faulting must not prevent the rest of Dispose from running. + } - _cts?.Cancel(); - _cts?.Dispose(); - _monitor?.Dispose(); - _changeSubject?.Dispose(); + _cts.Dispose(); + _monitor.Dispose(); + _changeSubject.Dispose(); } /// diff --git a/website/guide/providers/command-line.md b/website/guide/providers/command-line.md index 6469b6b..676a7be 100644 --- a/website/guide/providers/command-line.md +++ b/website/guide/providers/command-line.md @@ -37,6 +37,12 @@ The parser supports several formats: --Database__Port=5432 ``` +::: warning Values that start with a switch prefix +In the two-argument `--key value` form, a value that itself begins with a switch prefix (e.g. `--port -5`) +is parsed as a **boolean flag** (`--port` → `true`), not as the value `-5`. Use the `=` form for such +values: `--port=-5`. +::: + ## Prefix Filtering Filter arguments by a prefix to avoid collisions: From fdaa763d0c8866c8d365855ee3f9dd142edba785 Mon Sep 17 00:00:00 2001 From: Bernhard Windisch Date: Tue, 2 Jun 2026 17:13:42 +0200 Subject: [PATCH 06/10] feat(providers): extract FileBackedProvider base + add dotenv (FromDotEnv) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract a reusable FileBackedProvider abstract base from FileSourceProvider — it owns watching (resilient monitor + polling fallback), path-traversal/symlink security, per-query debounce, the change stream, and disposal, and delegates the one format-specific step to ParseToJsonBytes. FileSourceProvider is now a thin subclass (identity transform); behavior byte-identical (all File tests pass). This makes new file formats trivial. Add DotEnvProvider + FromDotEnv(path=.env) in core (no external dependency): KEY=value lines, # comments, optional export prefix, single/double quotes (double unescapes \n\t etc.), inline-comment stripping, and :/__ key nesting. Reactive via the file watcher. 5 tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../DotEnvProvider/DotEnvProvider.cs | 89 +++++++ .../DotEnvProvider/DotEnvRulesExtensions.cs | 32 +++ .../FileSourceProvider/FileBackedProvider.cs | 250 ++++++++++++++++++ .../FileSourceProvider/FileSourceProvider.cs | 237 +---------------- .../Providers/DotEnvProviderTests.cs | 131 +++++++++ 5 files changed, 509 insertions(+), 230 deletions(-) create mode 100644 src/Cocoar.Configuration/Providers/DotEnvProvider/DotEnvProvider.cs create mode 100644 src/Cocoar.Configuration/Providers/DotEnvProvider/DotEnvRulesExtensions.cs create mode 100644 src/Cocoar.Configuration/Providers/FileSourceProvider/FileBackedProvider.cs create mode 100644 src/tests/Cocoar.Configuration.Core.Tests/Providers/DotEnvProviderTests.cs diff --git a/src/Cocoar.Configuration/Providers/DotEnvProvider/DotEnvProvider.cs b/src/Cocoar.Configuration/Providers/DotEnvProvider/DotEnvProvider.cs new file mode 100644 index 0000000..c4b3c0a --- /dev/null +++ b/src/Cocoar.Configuration/Providers/DotEnvProvider/DotEnvProvider.cs @@ -0,0 +1,89 @@ +using System.Text; +using System.Text.Json; + +namespace Cocoar.Configuration.Providers; + +/// +/// Reads configuration from a .env file (12-factor style): KEY=value lines, # comments, +/// an optional export prefix, and quoted values. Keys nest with : or __ (e.g. +/// Db__Port=5432{ "Db": { "Port": "5432" } }), matching the environment-variable convention. +/// Values are emitted as JSON strings; the binder coerces them to the target type. Reactive: the file is watched +/// and re-parsed on change (via ). +/// +public sealed class DotEnvProvider(FileSourceProviderOptions options) : FileBackedProvider(options) +{ + protected override byte[] ParseToJsonBytes(byte[] rawFileBytes, string filename) + { + var text = Encoding.UTF8.GetString(rawFileBytes); + var root = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var rawLine in text.Split('\n')) + { + var line = rawLine.Trim(); + if (line.Length == 0 || line[0] == '#') + { + continue; + } + + if (line.StartsWith("export ", StringComparison.Ordinal)) + { + line = line["export ".Length..].TrimStart(); + } + + var eq = line.IndexOf('='); + if (eq <= 0) + { + continue; // no key, or no '=' + } + + var key = line[..eq].Trim(); + if (key.Length == 0) + { + continue; + } + + var value = Unquote(line[(eq + 1)..].Trim()); + AddNested(root, key, value); + } + + return JsonSerializer.SerializeToUtf8Bytes(root); + } + + private static string Unquote(string value) + { + if (value.Length >= 2) + { + var quote = value[0]; + if ((quote == '"' || quote == '\'') && value[^1] == quote) + { + var inner = value[1..^1]; + // Double-quoted values support common escapes; single-quoted are literal. + return quote == '"' + ? inner.Replace("\\n", "\n").Replace("\\t", "\t").Replace("\\\"", "\"").Replace("\\\\", "\\") + : inner; + } + } + + // Unquoted: strip a trailing inline comment (whitespace + '#'). + var comment = value.IndexOf(" #", StringComparison.Ordinal); + return comment >= 0 ? value[..comment].TrimEnd() : value; + } + + private static void AddNested(Dictionary root, string key, string value) + { + var segments = key.Split([":", "__"], StringSplitOptions.RemoveEmptyEntries); + var current = root; + for (var i = 0; i < segments.Length - 1; i++) + { + if (!current.TryGetValue(segments[i], out var next) || next is not Dictionary dict) + { + dict = new Dictionary(StringComparer.OrdinalIgnoreCase); + current[segments[i]] = dict; + } + + current = dict; + } + + current[segments[^1]] = value; + } +} diff --git a/src/Cocoar.Configuration/Providers/DotEnvProvider/DotEnvRulesExtensions.cs b/src/Cocoar.Configuration/Providers/DotEnvProvider/DotEnvRulesExtensions.cs new file mode 100644 index 0000000..531e5ac --- /dev/null +++ b/src/Cocoar.Configuration/Providers/DotEnvProvider/DotEnvRulesExtensions.cs @@ -0,0 +1,32 @@ +using Cocoar.Configuration.Core; +using Cocoar.Configuration.Fluent; + +namespace Cocoar.Configuration.Providers; + +public static class DotEnvRulesExtensions +{ + /// + /// Creates a configuration rule that reads a .env file (defaults to .env in the base directory). + /// + public static ProviderRuleBuilder + FromDotEnv(this TypedProviderBuilder builder, string filePath = ".env") + where T : class + => new( + cm => FileSourceRuleOptions.FromFilePath(filePath).ToProviderOptions(), + cm => FileSourceRuleOptions.FromFilePath(filePath).ToQueryOptions(), + typeof(T) + ); + + /// + /// Creates a .env rule from a config-aware path — e.g. a per-tenant path + /// a => $"tenants/{a.Tenant}/.env". The path is resolved from the accessor on each recompute. + /// + public static ProviderRuleBuilder + FromDotEnv(this TypedProviderBuilder builder, Func pathFactory) + where T : class + => new( + cm => FileSourceRuleOptions.FromFilePath(pathFactory(cm)).ToProviderOptions(), + cm => FileSourceRuleOptions.FromFilePath(pathFactory(cm)).ToQueryOptions(), + typeof(T) + ); +} diff --git a/src/Cocoar.Configuration/Providers/FileSourceProvider/FileBackedProvider.cs b/src/Cocoar.Configuration/Providers/FileSourceProvider/FileBackedProvider.cs new file mode 100644 index 0000000..9ee68fc --- /dev/null +++ b/src/Cocoar.Configuration/Providers/FileSourceProvider/FileBackedProvider.cs @@ -0,0 +1,250 @@ +using System.Collections.Concurrent; +using Cocoar.Configuration.Providers.Abstractions; +using Cocoar.FileSystem; + +namespace Cocoar.Configuration.Providers; + +/// +/// Base class for file-backed providers. It owns everything format-agnostic — directory resolution, +/// resilient watching (with polling fallback), path-traversal and symlink protection, per-query debounce, +/// the change stream, and disposal — and delegates the single format-specific step to +/// . A concrete provider only converts the file's raw bytes to the UTF-8 JSON +/// the pipeline merges (the JSON provider returns them unchanged; YAML/dotenv/etc. parse and re-serialize). +/// +public abstract class FileBackedProvider + : ConfigurationProvider, IDisposable +{ + private readonly ConcurrentDictionary> _changeBytesStreams = new(); + + private readonly SimpleSubject _changeSubject = new(); + private readonly ResilientFileSystemMonitor _monitor; + private readonly CancellationTokenSource _cts = new(); + private int _disposed; + + protected FileBackedProvider(FileSourceProviderOptions options) : base(options) + { + _monitor = ResilientFileSystemMonitor + .Watch(options.Directory, "*") + .WithPollingFallback(options.PollingInterval) + .Build(); + + // Background task for event processing — observe faults so they don't go unnoticed + var monitorTask = Task.Run(async () => await ProcessFileSystemEventsAsync(_cts.Token).ConfigureAwait(false)); + var providerName = GetType().Name; + monitorTask.ContinueWith( + t => System.Diagnostics.Debug.Fail( + $"{providerName}: file monitoring task faulted: {t.Exception?.GetBaseException().Message}"), + TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously); + } + + /// + /// Converts a file's raw bytes to the UTF-8 JSON bytes the configuration pipeline merges. Called on the + /// fetch path (where throwing signals a hard failure → Required rollback / Optional degrade) and on the + /// change path (where a throw degrades to {}). For an already-JSON file this returns the bytes + /// unchanged; for other formats, parse and serialize to JSON. + /// + /// The raw file contents (UTF-8 BOM already stripped). + /// The file name, for diagnostics. + protected abstract byte[] ParseToJsonBytes(byte[] rawFileBytes, string filename); + + private async Task ProcessFileSystemEventsAsync(CancellationToken ct) + { + try + { + await foreach (var evt in _monitor.Events.ReadAllAsync(ct).ConfigureAwait(false)) + { + var changeType = evt.Kind switch + { + FileSystemEventKind.Created => FileSystemChangeType.Created, + FileSystemEventKind.Changed => FileSystemChangeType.Changed, + FileSystemEventKind.Deleted => FileSystemChangeType.Deleted, + FileSystemEventKind.Renamed => FileSystemChangeType.Renamed, + _ => (FileSystemChangeType?)null + }; + + if (changeType.HasValue) + { + _changeSubject.OnNext(new(changeType.Value, evt.FullPath, evt.OldFullPath)); + } + } + } + catch (OperationCanceledException) + { + } + } + + public override Task FetchConfigurationBytesAsync(FileSourceProviderQueryOptions queryOptions, + CancellationToken ct = default) + { + var filename = queryOptions.Filename; + var json = ParseToJsonBytes(LoadRawFileBytes(filename), filename); + return Task.FromResult(json); + } + + public override IObservable ChangesAsBytes(FileSourceProviderQueryOptions queryOptions) + { + var filename = queryOptions.Filename; + return _changeBytesStreams.GetOrAdd(filename, fn => + { + IObservable filtered = ((IObservable)_changeSubject) + .Where(ev => Path.GetFileName(ev.Path).Equals(fn, StringComparison.OrdinalIgnoreCase) || + (ev.OldPath != null && Path.GetFileName(ev.OldPath) + .Equals(fn, StringComparison.OrdinalIgnoreCase))); + + // Apply per-query debounce if provided; default is no debounce + if (queryOptions.DebounceTime is { } d && d > TimeSpan.Zero) + { + filtered = new ThrottleObservable(filtered, d); + } + + return filtered.Select(_ => + { + try + { + return ParseToJsonBytes(LoadRawFileBytes(fn), fn); + } + catch + { + return "{}"u8.ToArray(); + } + }); + }); + } + + private byte[] LoadRawFileBytes(string filename) + { + var fullPath = Path.GetFullPath(Path.Combine(ProviderOptions.Directory, filename)); + + // Prevent path traversal attacks - ensure resolved path is within configured directory. + // Append trailing separator so "config_backup/../" can't escape a "config" base dir. + var baseDir = Path.GetFullPath(ProviderOptions.Directory); + var baseDirWithSep = baseDir.EndsWith(Path.DirectorySeparatorChar) + ? baseDir + : baseDir + Path.DirectorySeparatorChar; + if (!fullPath.StartsWith(baseDirWithSep, StringComparison.OrdinalIgnoreCase) + && !string.Equals(fullPath, baseDir, StringComparison.OrdinalIgnoreCase)) + { + throw new UnauthorizedAccessException( + $"Path traversal detected: filename '{filename}' resolves outside configured directory. " + + $"Resolved: {fullPath}, Expected base: {baseDir}"); + } + + // Throw specific exceptions so ConfigManager can handle Required vs Optional rules appropriately + if (!Directory.Exists(ProviderOptions.Directory)) + { + throw new DirectoryNotFoundException( + $"Config directory doesn't exist: {ProviderOptions.Directory}. " + + $"Check your path or mark the rule as Optional if this directory might not exist yet."); + } + + if (!File.Exists(fullPath)) + { + throw new FileNotFoundException( + $"Config file not found: {fullPath}. " + + $"If this file is created later at runtime, mark the rule as Optional.", fullPath); + } + + // Reject symlinks / reparse points to prevent symlink escape attacks + var fileInfo = new FileInfo(fullPath); + if ((fileInfo.Attributes & FileAttributes.ReparsePoint) != 0) + { + throw new UnauthorizedAccessException( + $"Symlinks are not allowed for config files: {fullPath}"); + } + + // Use FileReader for secure file reading with shared access and BOM handling + return FileReader.ReadAllBytes(fullPath, stripUtf8Bom: true); + } + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; // already disposed (race-safe) + } + + try + { + _cts.Cancel(); + } + catch (Exception) + { + // A cancellation callback faulting must not prevent the rest of Dispose from running. + } + + _cts.Dispose(); + _monitor.Dispose(); + _changeSubject.Dispose(); + } + + /// + /// Trailing-edge throttle (debounce): emits the last received value after a quiet period. + /// + private sealed class ThrottleObservable(IObservable source, TimeSpan dueTime) : IObservable + { + public IDisposable Subscribe(IObserver observer) + { + var state = new ThrottleState(observer, dueTime); + var sub = source.Subscribe(state); + return DisposableHelpers.Create(() => + { + sub.Dispose(); + state.Dispose(); + }); + } + + private sealed class ThrottleState(IObserver target, TimeSpan dueTime) : IObserver, IDisposable + { +#if NET9_0_OR_GREATER + private readonly Lock _lock = new(); +#else + private readonly object _lock = new(); +#endif + private Timer? _timer; + private T? _latestValue; + private bool _hasValue; + private bool _disposed; + + public void OnNext(T value) + { + lock (_lock) + { + if (_disposed) return; + _latestValue = value; + _hasValue = true; + if (_timer is null) + _timer = new Timer(Tick, null, dueTime, Timeout.InfiniteTimeSpan); + else + _timer.Change(dueTime, Timeout.InfiniteTimeSpan); + } + } + + public void OnError(Exception error) => target.OnError(error); + public void OnCompleted() => target.OnCompleted(); + + private void Tick(object? _) + { + T value; + lock (_lock) + { + if (!_hasValue || _disposed) return; + value = _latestValue!; + _hasValue = false; + } + + target.OnNext(value); + } + + public void Dispose() + { + lock (_lock) + { + _disposed = true; + _hasValue = false; + _timer?.Dispose(); + _timer = null; + } + } + } + } +} diff --git a/src/Cocoar.Configuration/Providers/FileSourceProvider/FileSourceProvider.cs b/src/Cocoar.Configuration/Providers/FileSourceProvider/FileSourceProvider.cs index cb7447d..7fcbb2c 100644 --- a/src/Cocoar.Configuration/Providers/FileSourceProvider/FileSourceProvider.cs +++ b/src/Cocoar.Configuration/Providers/FileSourceProvider/FileSourceProvider.cs @@ -1,234 +1,11 @@ -using System.Collections.Concurrent; -using System.Text.Json; -using Cocoar.Configuration.Providers.Abstractions; -using Cocoar.FileSystem; - namespace Cocoar.Configuration.Providers; -public sealed class FileSourceProvider : ConfigurationProvider, IDisposable +/// +/// Reads configuration from a JSON file. The file's bytes are already the JSON the pipeline merges, so this +/// provider adds no parsing on top of (which handles watching, path/symlink +/// security, debounce, and disposal). +/// +public sealed class FileSourceProvider(FileSourceProviderOptions options) : FileBackedProvider(options) { - private readonly ConcurrentDictionary> _changeBytesStreams = new(); - - private readonly SimpleSubject _changeSubject = new(); - private readonly ResilientFileSystemMonitor _monitor; - private readonly CancellationTokenSource _cts = new(); - private int _disposed; - - public FileSourceProvider(FileSourceProviderOptions options) : base(options) - { - _monitor = ResilientFileSystemMonitor - .Watch(options.Directory, "*") - .WithPollingFallback(options.PollingInterval) - .Build(); - - // Background task for event processing — observe faults so they don't go unnoticed - var monitorTask = Task.Run(async () => await ProcessFileSystemEventsAsync(_cts.Token).ConfigureAwait(false)); - monitorTask.ContinueWith( - static t => System.Diagnostics.Debug.Fail( - $"FileSourceProvider: file monitoring task faulted: {t.Exception?.GetBaseException().Message}"), - TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously); - } - - private async Task ProcessFileSystemEventsAsync(CancellationToken ct) - { - try - { - await foreach (var evt in _monitor.Events.ReadAllAsync(ct).ConfigureAwait(false)) - { - var changeType = evt.Kind switch - { - FileSystemEventKind.Created => FileSystemChangeType.Created, - FileSystemEventKind.Changed => FileSystemChangeType.Changed, - FileSystemEventKind.Deleted => FileSystemChangeType.Deleted, - FileSystemEventKind.Renamed => FileSystemChangeType.Renamed, - _ => (FileSystemChangeType?)null - }; - - if (changeType.HasValue) - { - _changeSubject.OnNext(new(changeType.Value, evt.FullPath, evt.OldFullPath)); - } - } - } - catch (OperationCanceledException) - { - } - } - - public override Task FetchConfigurationBytesAsync(FileSourceProviderQueryOptions queryOptions, - CancellationToken ct = default) - { - var filename = queryOptions.Filename; - var bytes = LoadFileBytes(filename); - return Task.FromResult(bytes); - } - - public override IObservable ChangesAsBytes(FileSourceProviderQueryOptions queryOptions) - { - var filename = queryOptions.Filename; - return _changeBytesStreams.GetOrAdd(filename, fn => - { - IObservable filtered = ((IObservable)_changeSubject) - .Where(ev => Path.GetFileName(ev.Path).Equals(fn, StringComparison.OrdinalIgnoreCase) || - (ev.OldPath != null && Path.GetFileName(ev.OldPath) - .Equals(fn, StringComparison.OrdinalIgnoreCase))); - - // Apply per-query debounce if provided; default is no debounce - if (queryOptions.DebounceTime is { } d && d > TimeSpan.Zero) - { - filtered = new ThrottleObservable(filtered, d); - } - - return filtered.Select(_ => - { - byte[] newBytes; - try - { - newBytes = LoadFileBytes(fn); - } - catch - { - newBytes = "{}"u8.ToArray(); - } - return newBytes; - }); - }); - } - - private byte[] LoadFileBytes(string filename) - { - var fullPath = Path.GetFullPath(Path.Combine(ProviderOptions.Directory, filename)); - - // Prevent path traversal attacks - ensure resolved path is within configured directory. - // Append trailing separator so "config_backup/../" can't escape a "config" base dir. - var baseDir = Path.GetFullPath(ProviderOptions.Directory); - var baseDirWithSep = baseDir.EndsWith(Path.DirectorySeparatorChar) - ? baseDir - : baseDir + Path.DirectorySeparatorChar; - if (!fullPath.StartsWith(baseDirWithSep, StringComparison.OrdinalIgnoreCase) - && !string.Equals(fullPath, baseDir, StringComparison.OrdinalIgnoreCase)) - { - throw new UnauthorizedAccessException( - $"Path traversal detected: filename '{filename}' resolves outside configured directory. " + - $"Resolved: {fullPath}, Expected base: {baseDir}"); - } - - // Throw specific exceptions so ConfigManager can handle Required vs Optional rules appropriately - if (!Directory.Exists(ProviderOptions.Directory)) - { - throw new DirectoryNotFoundException( - $"Config directory doesn't exist: {ProviderOptions.Directory}. " + - $"Check your path or mark the rule as Optional if this directory might not exist yet."); - } - - if (!File.Exists(fullPath)) - { - throw new FileNotFoundException( - $"Config file not found: {fullPath}. " + - $"If this file is created later at runtime, mark the rule as Optional.", fullPath); - } - - // Reject symlinks / reparse points to prevent symlink escape attacks - var fileInfo = new FileInfo(fullPath); - if ((fileInfo.Attributes & FileAttributes.ReparsePoint) != 0) - { - throw new UnauthorizedAccessException( - $"Symlinks are not allowed for config files: {fullPath}"); - } - - // Use FileReader for secure file reading with shared access and BOM handling - return FileReader.ReadAllBytes(fullPath, stripUtf8Bom: true); - } - - public void Dispose() - { - if (Interlocked.Exchange(ref _disposed, 1) != 0) - { - return; // already disposed (race-safe) - } - - try - { - _cts.Cancel(); - } - catch (Exception) - { - // A cancellation callback faulting must not prevent the rest of Dispose from running. - } - - _cts.Dispose(); - _monitor.Dispose(); - _changeSubject.Dispose(); - } - - /// - /// Trailing-edge throttle (debounce): emits the last received value after a quiet period. - /// - private sealed class ThrottleObservable(IObservable source, TimeSpan dueTime) : IObservable - { - public IDisposable Subscribe(IObserver observer) - { - var state = new ThrottleState(observer, dueTime); - var sub = source.Subscribe(state); - return DisposableHelpers.Create(() => - { - sub.Dispose(); - state.Dispose(); - }); - } - - private sealed class ThrottleState(IObserver target, TimeSpan dueTime) : IObserver, IDisposable - { -#if NET9_0_OR_GREATER - private readonly Lock _lock = new(); -#else - private readonly object _lock = new(); -#endif - private Timer? _timer; - private T? _latestValue; - private bool _hasValue; - private bool _disposed; - - public void OnNext(T value) - { - lock (_lock) - { - if (_disposed) return; - _latestValue = value; - _hasValue = true; - if (_timer is null) - _timer = new Timer(Tick, null, dueTime, Timeout.InfiniteTimeSpan); - else - _timer.Change(dueTime, Timeout.InfiniteTimeSpan); - } - } - - public void OnError(Exception error) => target.OnError(error); - public void OnCompleted() => target.OnCompleted(); - - private void Tick(object? _) - { - T value; - lock (_lock) - { - if (!_hasValue || _disposed) return; - value = _latestValue!; - _hasValue = false; - } - - target.OnNext(value); - } - - public void Dispose() - { - lock (_lock) - { - _disposed = true; - _hasValue = false; - _timer?.Dispose(); - _timer = null; - } - } - } - } + protected override byte[] ParseToJsonBytes(byte[] rawFileBytes, string filename) => rawFileBytes; } diff --git a/src/tests/Cocoar.Configuration.Core.Tests/Providers/DotEnvProviderTests.cs b/src/tests/Cocoar.Configuration.Core.Tests/Providers/DotEnvProviderTests.cs new file mode 100644 index 0000000..64b58b8 --- /dev/null +++ b/src/tests/Cocoar.Configuration.Core.Tests/Providers/DotEnvProviderTests.cs @@ -0,0 +1,131 @@ +using System.Text; +using System.Text.Json; +using Cocoar.Configuration.Core; +using Cocoar.Configuration.Providers; + +namespace Cocoar.Configuration.Core.Tests.Providers; + +public class DotEnvProviderTests : IDisposable +{ + private readonly DirectoryInfo _dir = Directory.CreateTempSubdirectory("cocoar-dotenv-"); + + private JsonElement Parse(string content, string filename = ".env") + { + File.WriteAllText(Path.Combine(_dir.FullName, filename), content); + var provider = new DotEnvProvider(new FileSourceProviderOptions(_dir.FullName)); + var bytes = provider.FetchConfigurationBytesAsync(new FileSourceProviderQueryOptions(filename)) + .GetAwaiter().GetResult(); + return JsonDocument.Parse(Encoding.UTF8.GetString(bytes)).RootElement; + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "DotEnvProvider")] + public void Parses_keys_comments_blanks_and_export_prefix() + { + var json = Parse( + """ + # a comment + NAME=myapp + + export TOKEN=abc123 + """); + + Assert.Equal("myapp", json.GetProperty("NAME").GetString()); + Assert.Equal("abc123", json.GetProperty("TOKEN").GetString()); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "DotEnvProvider")] + public void Nests_keys_on_double_underscore_and_colon() + { + var json = Parse( + """ + Db__Port=5432 + Db:Host=localhost + """); + + Assert.Equal("5432", json.GetProperty("Db").GetProperty("Port").GetString()); + Assert.Equal("localhost", json.GetProperty("Db").GetProperty("Host").GetString()); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "DotEnvProvider")] + public void Handles_quotes_and_inline_comments() + { + var json = Parse( + """ + DQ="hello world" + SQ='literal $x' + ESC="line1\nline2" + INLINE=value # trailing comment + HASHVALUE=pa#ss + """); + + Assert.Equal("hello world", json.GetProperty("DQ").GetString()); + Assert.Equal("literal $x", json.GetProperty("SQ").GetString()); // single-quote = literal, no escapes + Assert.Equal("line1\nline2", json.GetProperty("ESC").GetString()); // double-quote unescapes \n + Assert.Equal("value", json.GetProperty("INLINE").GetString()); // trailing ' #...' stripped + Assert.Equal("pa#ss", json.GetProperty("HASHVALUE").GetString()); // '#' without leading space kept + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "DotEnvProvider")] + public void Empty_or_keyless_lines_are_ignored() + { + var json = Parse( + """ + =novalue + JUSTAKEY + REAL=1 + """); + + Assert.False(json.TryGetProperty("JUSTAKEY", out _)); + Assert.Equal("1", json.GetProperty("REAL").GetString()); + Assert.Equal(1, json.EnumerateObject().Count()); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "DotEnvProvider")] + public void Binds_through_ConfigManager_via_FromDotEnv() + { + File.WriteAllText(Path.Combine(_dir.FullName, "app.env"), + """ + Name=myapp + export Db__Port=5432 + Db:Host="localhost" + """); + + using var manager = ConfigManager.Create(c => c + .UseConfiguration(rules => + [ + rules.For().FromDotEnv(Path.Combine(_dir.FullName, "app.env")) + ])); + + var cfg = manager.GetConfig()!; + Assert.Equal("myapp", cfg.Name); + Assert.Equal(5432, cfg.Db.Port); + Assert.Equal("localhost", cfg.Db.Host); + } + + public sealed class AppCfg + { + public string? Name { get; set; } + public DbCfg Db { get; set; } = new(); + } + + public sealed class DbCfg + { + public int Port { get; set; } + public string? Host { get; set; } + } + + public void Dispose() + { + try { _dir.Delete(recursive: true); } catch { /* best-effort temp cleanup */ } + } +} From 52fec5d46ae6835cb6c9c52094a06da316c484b0 Mon Sep 17 00:00:00 2001 From: Bernhard Windisch Date: Tue, 2 Jun 2026 17:23:10 +0200 Subject: [PATCH 07/10] feat(yaml): add Cocoar.Configuration.Yaml provider + YAML/dotenv docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New opt-in package Cocoar.Configuration.Yaml: YamlFileProvider (a FileBackedProvider subclass) + FromYamlFile(path) with reactive watching. Plain YAML scalars are mapped to JSON types via the representation model with scalar-style awareness (bool/number/null inferred; quoted '"…"'/'…' and block |/> scalars stay strings). YamlDotNet 18.0.0, net9/net10. 4 tests. Docs: new VitePress YAML + dotenv provider pages (+ sidebar, description frontmatter), packages.md, CLAUDE.md, changelog [Unreleased]. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 19 +++ CLAUDE.md | 1 + .../Cocoar.Configuration.Yaml.csproj | 19 +++ .../YamlFileProvider.cs | 110 +++++++++++++++ .../YamlRulesExtensions.cs | 33 +++++ src/Cocoar.Configuration.slnx | 2 + src/Directory.Packages.props | 1 + .../Cocoar.Configuration.Yaml.Tests.csproj | 28 ++++ .../YamlFileProviderTests.cs | 133 ++++++++++++++++++ website/.vitepress/config.ts | 2 + website/changelog.md | 19 +++ website/guide/providers/dotenv.md | 46 ++++++ website/guide/providers/yaml.md | 48 +++++++ website/reference/packages.md | 12 ++ 14 files changed, 473 insertions(+) create mode 100644 src/Cocoar.Configuration.Yaml/Cocoar.Configuration.Yaml.csproj create mode 100644 src/Cocoar.Configuration.Yaml/YamlFileProvider.cs create mode 100644 src/Cocoar.Configuration.Yaml/YamlRulesExtensions.cs create mode 100644 src/tests/Cocoar.Configuration.Yaml.Tests/Cocoar.Configuration.Yaml.Tests.csproj create mode 100644 src/tests/Cocoar.Configuration.Yaml.Tests/YamlFileProviderTests.cs create mode 100644 website/guide/providers/dotenv.md create mode 100644 website/guide/providers/yaml.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 19cd3b7..f3ef169 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog +## [Unreleased] + +### Added +- **`Cocoar.Configuration.Yaml`** — new opt-in YAML file provider (`FromYamlFile`) with reactive file-watching. Plain YAML scalars map to JSON types (booleans, numbers, null); quoted and block scalars stay strings. +- **dotenv** — `FromDotEnv(path)` built into the core package (no dependency): `KEY=value`, `#` comments, optional `export` prefix, quotes, inline comments, and `:`/`__` key nesting; reactive. + +### Changed +- Extracted a reusable `FileBackedProvider` base (watching, path/symlink security, debounce, disposal); `FileSourceProvider` is now a thin subclass (behavior unchanged). +- Documented the provider-contract invariants on `ConfigurationProvider` / `IProviderConfiguration`. +- Added a parameterless `FromCommandLine()`. + +### Fixed +- **Observable provider**: fetch no longer hangs on a cold / complete-without-emit source, and no longer leaks its one-shot subscription. +- **HTTP provider**: dispose the `HttpResponseMessage` after fetch; optional `SseReadIdleTimeout` reconnects a half-open SSE stream. +- `FileSourceProvider.Dispose` is race-safe and guards cancellation. + +### Removed +- Dead internal `MicrosoftConfigurationSource*` types (the `FromMicrosoftSource` API was removed in 6.0.0). + ## [6.0.0] - 2026-06-01 > Major release. The headline change is the move off .NET 8. diff --git a/CLAUDE.md b/CLAUDE.md index dfca97a..199e381 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -91,6 +91,7 @@ SetupDefinition.GetComposer(builder).Add(new ServiceLifetimeCapability(...)); | `Cocoar.Configuration.Http` | Remote config provider (polling, SSE, one-time fetch) | | `Cocoar.Configuration.MicrosoftAdapter` | Bridge to existing `IConfiguration` sources | | `Cocoar.Configuration.WritableStore.Marten` | Marten (PostgreSQL) WritableStore backend (`MartenStoreBackend`, `FromMartenStore()`); tenant-aware via Marten database-per-tenant. Opt-in integration package that takes a Marten dependency. | +| `Cocoar.Configuration.Yaml` | YAML file provider (`FromYamlFile`), reactive watching, scalar type-inference. Opt-in; takes a YamlDotNet dependency. (dotenv `FromDotEnv` lives in core — no dependency.) | | `Cocoar.Configuration.Analyzers` | Roslyn analyzers (COCFG001, 002, 003, 005, 006) and source generator (COCFLAG001-003). COCFG004 was removed — enforced by `where T : class` constraint instead. | | `Cocoar.Configuration.Secrets.Cli` | Global .NET tool for encrypting/decrypting secrets in config files | diff --git a/src/Cocoar.Configuration.Yaml/Cocoar.Configuration.Yaml.csproj b/src/Cocoar.Configuration.Yaml/Cocoar.Configuration.Yaml.csproj new file mode 100644 index 0000000..32b1c15 --- /dev/null +++ b/src/Cocoar.Configuration.Yaml/Cocoar.Configuration.Yaml.csproj @@ -0,0 +1,19 @@ + + + + true + enable + enable + YAML file configuration provider for Cocoar.Configuration. Reads .yaml/.yml files into the configuration pipeline with reactive file-watching, mapping YAML scalars to their JSON types (booleans, numbers, null) so they bind like JSON config. + configuration;yaml;yml;file;reactive;yamldotnet + + + + + + + + + + + diff --git a/src/Cocoar.Configuration.Yaml/YamlFileProvider.cs b/src/Cocoar.Configuration.Yaml/YamlFileProvider.cs new file mode 100644 index 0000000..899cbc4 --- /dev/null +++ b/src/Cocoar.Configuration.Yaml/YamlFileProvider.cs @@ -0,0 +1,110 @@ +using System.Globalization; +using System.Text; +using System.Text.Json.Nodes; +using Cocoar.Configuration.Providers; +using YamlDotNet.Core; +using YamlDotNet.RepresentationModel; + +namespace Cocoar.Configuration.Yaml; + +/// +/// Reads configuration from a YAML file. Converts YAML to the UTF-8 JSON the pipeline merges, mapping +/// plain scalars to their JSON types (YAML core schema: true/false → boolean, +/// integers/floats → number, null/~ → null) so values bind like JSON. Quoted and block scalars +/// stay strings. Reactive: the file is watched and re-parsed on change (via ). +/// +public sealed class YamlFileProvider(FileSourceProviderOptions options) : FileBackedProvider(options) +{ + protected override byte[] ParseToJsonBytes(byte[] rawFileBytes, string filename) + { + var yaml = Encoding.UTF8.GetString(rawFileBytes); + if (string.IsNullOrWhiteSpace(yaml)) + { + return "{}"u8.ToArray(); + } + + var stream = new YamlStream(); + stream.Load(new StringReader(yaml)); + if (stream.Documents.Count == 0) + { + return "{}"u8.ToArray(); + } + + var node = ConvertNode(stream.Documents[0].RootNode); + return node is null ? "{}"u8.ToArray() : Encoding.UTF8.GetBytes(node.ToJsonString()); + } + + private static JsonNode? ConvertNode(YamlNode node) + { + switch (node) + { + case YamlMappingNode map: + var obj = new JsonObject(); + foreach (var entry in map.Children) + { + var key = (entry.Key as YamlScalarNode)?.Value ?? entry.Key.ToString(); + obj[key ?? string.Empty] = ConvertNode(entry.Value); + } + + return obj; + + case YamlSequenceNode seq: + var arr = new JsonArray(); + foreach (var item in seq.Children) + { + arr.Add(ConvertNode(item)); + } + + return arr; + + case YamlScalarNode scalar: + return ConvertScalar(scalar); + + default: + return null; + } + } + + private static JsonNode? ConvertScalar(YamlScalarNode scalar) + { + // Only PLAIN scalars get YAML core-schema type inference; quoted ("…"/'…') and block (|/>) scalars + // are always strings. + if (scalar.Style != ScalarStyle.Plain) + { + return JsonValue.Create(scalar.Value ?? string.Empty); + } + + var v = scalar.Value; + if (string.IsNullOrEmpty(v)) + { + return null; // a plain empty scalar (`key:`) is null + } + + if (v is "null" or "Null" or "NULL" or "~") + { + return null; + } + + if (v is "true" or "True" or "TRUE") + { + return JsonValue.Create(true); + } + + if (v is "false" or "False" or "FALSE") + { + return JsonValue.Create(false); + } + + if (long.TryParse(v, NumberStyles.Integer, CultureInfo.InvariantCulture, out var l)) + { + return JsonValue.Create(l); + } + + if (double.TryParse(v, NumberStyles.Float, CultureInfo.InvariantCulture, out var d)) + { + return JsonValue.Create(d); + } + + return JsonValue.Create(v); + } +} diff --git a/src/Cocoar.Configuration.Yaml/YamlRulesExtensions.cs b/src/Cocoar.Configuration.Yaml/YamlRulesExtensions.cs new file mode 100644 index 0000000..1b63919 --- /dev/null +++ b/src/Cocoar.Configuration.Yaml/YamlRulesExtensions.cs @@ -0,0 +1,33 @@ +using Cocoar.Configuration.Core; +using Cocoar.Configuration.Fluent; +using Cocoar.Configuration.Providers; + +namespace Cocoar.Configuration.Yaml; + +public static class YamlRulesExtensions +{ + /// + /// Creates a configuration rule that reads a YAML file (.yaml/.yml), watched for changes. + /// + public static ProviderRuleBuilder + FromYamlFile(this TypedProviderBuilder builder, string filePath) + where T : class + => new( + cm => FileSourceRuleOptions.FromFilePath(filePath).ToProviderOptions(), + cm => FileSourceRuleOptions.FromFilePath(filePath).ToQueryOptions(), + typeof(T) + ); + + /// + /// Creates a YAML rule from a config-aware path — e.g. a per-tenant path + /// a => $"tenants/{a.Tenant}/config.yaml". The path is resolved from the accessor on each recompute. + /// + public static ProviderRuleBuilder + FromYamlFile(this TypedProviderBuilder builder, Func pathFactory) + where T : class + => new( + cm => FileSourceRuleOptions.FromFilePath(pathFactory(cm)).ToProviderOptions(), + cm => FileSourceRuleOptions.FromFilePath(pathFactory(cm)).ToQueryOptions(), + typeof(T) + ); +} diff --git a/src/Cocoar.Configuration.slnx b/src/Cocoar.Configuration.slnx index 1c22214..863d499 100644 --- a/src/Cocoar.Configuration.slnx +++ b/src/Cocoar.Configuration.slnx @@ -14,6 +14,7 @@ + @@ -44,4 +45,5 @@ + diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index c63a46d..676f588 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -7,6 +7,7 @@ + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/tests/Cocoar.Configuration.Yaml.Tests/Cocoar.Configuration.Yaml.Tests.csproj b/src/tests/Cocoar.Configuration.Yaml.Tests/Cocoar.Configuration.Yaml.Tests.csproj new file mode 100644 index 0000000..67fa4fb --- /dev/null +++ b/src/tests/Cocoar.Configuration.Yaml.Tests/Cocoar.Configuration.Yaml.Tests.csproj @@ -0,0 +1,28 @@ + + + + net10.0 + enable + enable + true + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + diff --git a/src/tests/Cocoar.Configuration.Yaml.Tests/YamlFileProviderTests.cs b/src/tests/Cocoar.Configuration.Yaml.Tests/YamlFileProviderTests.cs new file mode 100644 index 0000000..d152cad --- /dev/null +++ b/src/tests/Cocoar.Configuration.Yaml.Tests/YamlFileProviderTests.cs @@ -0,0 +1,133 @@ +using System.Text; +using System.Text.Json; +using Cocoar.Configuration.Core; +using Cocoar.Configuration.Providers; +using Cocoar.Configuration.Yaml; + +namespace Cocoar.Configuration.Yaml.Tests; + +public class YamlFileProviderTests : IDisposable +{ + private readonly DirectoryInfo _dir = Directory.CreateTempSubdirectory("cocoar-yaml-"); + + private JsonElement Parse(string yaml, string filename = "config.yaml") + { + File.WriteAllText(Path.Combine(_dir.FullName, filename), yaml); + var provider = new YamlFileProvider(new FileSourceProviderOptions(_dir.FullName)); + var bytes = provider.FetchConfigurationBytesAsync(new FileSourceProviderQueryOptions(filename)) + .GetAwaiter().GetResult(); + return JsonDocument.Parse(Encoding.UTF8.GetString(bytes)).RootElement; + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "YamlFileProvider")] + public void Infers_scalar_types_for_plain_scalars() + { + var json = Parse( + """ + enabled: true + disabled: false + port: 5432 + ratio: 1.5 + missing: null + name: hello + quoted: "true" + """); + + Assert.Equal(JsonValueKind.True, json.GetProperty("enabled").ValueKind); + Assert.Equal(JsonValueKind.False, json.GetProperty("disabled").ValueKind); + Assert.Equal(5432, json.GetProperty("port").GetInt32()); + Assert.Equal(1.5, json.GetProperty("ratio").GetDouble()); + Assert.Equal(JsonValueKind.Null, json.GetProperty("missing").ValueKind); + Assert.Equal("hello", json.GetProperty("name").GetString()); + Assert.Equal(JsonValueKind.String, json.GetProperty("quoted").ValueKind); // quoted stays a string + Assert.Equal("true", json.GetProperty("quoted").GetString()); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "YamlFileProvider")] + public void Maps_nested_objects_and_sequences() + { + var json = Parse( + """ + db: + host: localhost + port: 5432 + hosts: + - a + - b + - c + """); + + Assert.Equal("localhost", json.GetProperty("db").GetProperty("host").GetString()); + Assert.Equal(5432, json.GetProperty("db").GetProperty("port").GetInt32()); + var hosts = json.GetProperty("hosts"); + Assert.Equal(JsonValueKind.Array, hosts.ValueKind); + Assert.Equal(3, hosts.GetArrayLength()); + Assert.Equal("b", hosts[1].GetString()); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "YamlFileProvider")] + public void Empty_file_yields_empty_object() + { + Assert.Equal(JsonValueKind.Object, Parse("").ValueKind); + Assert.Empty(Parse("").EnumerateObject()); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "YamlFileProvider")] + public void Binds_through_ConfigManager_via_FromYamlFile() + { + File.WriteAllText(Path.Combine(_dir.FullName, "app.yaml"), + """ + name: myapp + enabled: true + ratio: 1.5 + note: "true" + db: + port: 5432 + hosts: + - h1 + - h2 + """); + + using var manager = ConfigManager.Create(c => c + .UseConfiguration(rules => + [ + rules.For().FromYamlFile(Path.Combine(_dir.FullName, "app.yaml")) + ])); + + var cfg = manager.GetConfig()!; + Assert.Equal("myapp", cfg.Name); + Assert.True(cfg.Enabled); + Assert.Equal(1.5, cfg.Ratio); + Assert.Equal("true", cfg.Note); + Assert.Equal(5432, cfg.Db.Port); + Assert.Equal(new[] { "h1", "h2" }, cfg.Db.Hosts); + } + + public sealed class AppCfg + { + public string? Name { get; set; } + public bool Enabled { get; set; } + public double Ratio { get; set; } + public string? Note { get; set; } + public DbCfg Db { get; set; } = new(); + } + + public sealed class DbCfg + { + public int Port { get; set; } + public List Hosts { get; set; } = new(); + } + + public void Dispose() + { + try { _dir.Delete(recursive: true); } catch { /* best-effort temp cleanup */ } + } +} diff --git a/website/.vitepress/config.ts b/website/.vitepress/config.ts index 2f031af..82025ed 100644 --- a/website/.vitepress/config.ts +++ b/website/.vitepress/config.ts @@ -62,6 +62,8 @@ export default defineConfig({ items: [ { text: 'Overview', link: '/guide/providers/overview' }, { text: 'File', link: '/guide/providers/file' }, + { text: 'YAML', link: '/guide/providers/yaml' }, + { text: 'Dotenv (.env)', link: '/guide/providers/dotenv' }, { text: 'Environment Variables', link: '/guide/providers/environment' }, { text: 'Command Line', link: '/guide/providers/command-line' }, { text: 'HTTP Polling', link: '/guide/providers/http-polling' }, diff --git a/website/changelog.md b/website/changelog.md index 72dec2b..ac01736 100644 --- a/website/changelog.md +++ b/website/changelog.md @@ -1,5 +1,24 @@ # Changelog +## [Unreleased] + +### Added +- **`Cocoar.Configuration.Yaml`** — new opt-in YAML file provider (`FromYamlFile`) with reactive file-watching. Plain YAML scalars map to JSON types (`true`/`false` → boolean, integers/floats → number, `null`/`~` → null); quoted and block scalars stay strings. +- **dotenv** — `FromDotEnv(path)` built into the core package (no dependency): `KEY=value` lines, `#` comments, optional `export` prefix, single/double quotes, inline comments, and `:`/`__` key nesting; reactive. + +### Changed +- Extracted a reusable `FileBackedProvider` base (watching, path/symlink security, debounce, disposal) so file-format providers share one implementation; `FileSourceProvider` is now a thin subclass (behavior unchanged). +- Documented the provider-contract invariants on `ConfigurationProvider` / `IProviderConfiguration`. +- Added a parameterless `FromCommandLine()` (default `--`, no key filter). + +### Fixed +- **Observable provider**: fetch no longer hangs on a cold / complete-without-emit source, and no longer leaks its one-shot subscription on synchronous replay. +- **HTTP provider**: the `HttpResponseMessage` is now disposed after fetch; added optional `SseReadIdleTimeout` that reconnects a half-open SSE stream. +- `FileSourceProvider.Dispose` is race-safe and guards cancellation. + +### Removed +- Dead internal `MicrosoftConfigurationSource*` types (the `FromMicrosoftSource` API was removed in 6.0.0). + ## [6.0.0] — 2026-06-01 > Major release. The headline change is the move off .NET 8. diff --git a/website/guide/providers/dotenv.md b/website/guide/providers/dotenv.md new file mode 100644 index 0000000..5a871fa --- /dev/null +++ b/website/guide/providers/dotenv.md @@ -0,0 +1,46 @@ +--- +description: "FromDotEnv provider (core, no dependency) — .env KEY=value parsing, # comments, export prefix, single/double quotes, inline comments, :/__ key nesting, reactive file-watching" +--- + +# Dotenv (.env) Provider + +`FromDotEnv` reads a 12-factor-style `.env` file into the configuration pipeline. It is **built into the core package** (no extra dependency) and uses the same reactive file-watching as the [File provider](/guide/providers/file). + +```csharp +builder.AddCocoarConfiguration(c => c + .UseConfiguration(rules => + [ + rules.For().FromDotEnv(), // defaults to ".env" + rules.For().FromDotEnv("local.env"), + ])); +``` + +## Format + +```shell +# comments and blank lines are ignored +NAME=myapp +export TOKEN=abc123 # an optional `export` prefix is stripped + +DQ="hello world" # double quotes; supports \n \t \" \\ escapes +SQ='literal $x' # single quotes are literal +INLINE=value # trailing comment (stripped — needs a leading space) + +# Nested keys with : or __ (like environment variables) +Db__Port=5432 # → { "Db": { "Port": "5432" } } +Db:Host=localhost +``` + +Values are emitted as strings; the binder coerces them to the target type (e.g. `Db__Port=5432` binds to an `int`). Keys nest on `:` or `__`, matching the [Environment Variables provider](/guide/providers/environment). + +## Reactivity & per-tenant paths + +Editing the file triggers a recompute. A config-aware overload resolves the path per recompute: + +```csharp +rules.For().FromDotEnv(a => $"tenants/{a.Tenant}/.env").TenantScoped() +``` + +## YAML? + +For `.yaml` / `.yml` files see the [YAML provider](/guide/providers/yaml) (`Cocoar.Configuration.Yaml`). diff --git a/website/guide/providers/yaml.md b/website/guide/providers/yaml.md new file mode 100644 index 0000000..d42d448 --- /dev/null +++ b/website/guide/providers/yaml.md @@ -0,0 +1,48 @@ +--- +description: "FromYamlFile provider (Cocoar.Configuration.Yaml) — reactive .yaml/.yml watching, YAML core-schema scalar type-inference (bool/number/null), quoted/block scalars stay strings" +--- + +# YAML Provider + +`Cocoar.Configuration.Yaml` reads `.yaml` / `.yml` files into the configuration pipeline, with the same reactive file-watching, path resolution, and security as the [File provider](/guide/providers/file). Opt-in package (it takes a YamlDotNet dependency). + +```shell +dotnet add package Cocoar.Configuration.Yaml +``` + +```csharp +using Cocoar.Configuration.Yaml; + +builder.AddCocoarConfiguration(c => c + .UseConfiguration(rules => + [ + rules.For().FromYamlFile("appsettings.yaml"), + ])); +``` + +## Scalar types + +Plain (unquoted) scalars are mapped to their JSON types, so a YAML file binds exactly like the equivalent JSON: + +| YAML | Binds as | +|---|---| +| `enabled: true` | boolean | +| `port: 5432` | number | +| `ratio: 1.5` | number | +| `note: null` (or `~`) | null | +| `name: hello` | string | +| `note: "true"` | **string** (quoted) | + +Quoted (`"…"` / `'…'`) and block (`|`, `>`) scalars are always strings. + +## Reactivity & per-tenant paths + +Editing the file triggers a recompute (same watcher as `FromFile`). A config-aware overload resolves the path per recompute — e.g. per tenant: + +```csharp +rules.For().FromYamlFile(a => $"tenants/{a.Tenant}/branding.yaml").TenantScoped() +``` + +## Looking for `.env`? + +The dotenv provider (`FromDotEnv`) is built into the **core** package — see [Dotenv](/guide/providers/dotenv). diff --git a/website/reference/packages.md b/website/reference/packages.md index 01f6473..5c56e54 100644 --- a/website/reference/packages.md +++ b/website/reference/packages.md @@ -94,6 +94,18 @@ Marten (PostgreSQL document store) backend for the WritableStore. Persists writa ``` +### Cocoar.Configuration.Yaml + +YAML file provider. Reads `.yaml`/`.yml` files into the configuration pipeline with reactive file-watching. Plain YAML scalars are mapped to their JSON types (booleans, numbers, null) so they bind like JSON; quoted and block scalars stay strings. Opt-in package — it takes a YamlDotNet dependency. (The `.env` / dotenv provider, `FromDotEnv()`, is built into the core package and needs no dependency.) + +- **Target:** .NET 9.0 / .NET 10.0 +- **Dependencies:** Cocoar.Configuration, YamlDotNet +- **Key types:** `YamlFileProvider`, `FromYamlFile()` extension method + +```xml + +``` + ### Cocoar.Configuration.Analyzers Roslyn analyzers (COCFG001–006) and source generator (COCFLAG001–003). Ships as a build-time dependency of the core package — you don't need to install it separately. From da862aea7b2e32bfdb8f2685a16020e7353fdfe4 Mon Sep 17 00:00:00 2001 From: Bernhard Windisch Date: Tue, 2 Jun 2026 23:47:29 +0200 Subject: [PATCH 08/10] feat(providers): Kubernetes ConfigMap/Secret support via followSymlinks A ConfigMap-mounted file is a symlink whose content is updated by an atomic swap of a sibling `..data` symlink, not by rewriting the file. New opt-in `followSymlinks` on FromFile/FromYamlFile/FromDotEnv (and FileSourceProviderOptions.FollowSymlinks), default off: - Read path: resolve the symlink's final target and require it to stay within the configured directory (escaping symlinks still rejected), in place of the blanket reparse-point rejection. - Watch path: wire Cocoar.FileSystem 2.3.0 WithSymlinkTargetTracking() so the atomic ..data swap is detected and hot-reloaded. Bumps Cocoar.FileSystem 2.2.0 -> 2.3.0. Default behavior unchanged (symlinks rejected as before). +3 tests; provider docs + changelog. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 1 + .../YamlRulesExtensions.cs | 14 +-- .../DotEnvProvider/DotEnvRulesExtensions.cs | 14 +-- .../FileSourceProvider/FileBackedProvider.cs | 44 +++++++-- .../FileSourceProviderOptions.cs | 16 ++- .../FileSourceRuleOptions.cs | 12 ++- .../FileSourceRulesExtensions.cs | 15 +-- src/Directory.Packages.props | 2 +- .../File/FileProviderSecurityTests.cs | 97 +++++++++++++++++++ website/changelog.md | 1 + website/guide/providers/dotenv.md | 2 +- website/guide/providers/file.md | 28 +++++- website/guide/providers/yaml.md | 2 +- 13 files changed, 211 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f3ef169..5208d47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Added - **`Cocoar.Configuration.Yaml`** — new opt-in YAML file provider (`FromYamlFile`) with reactive file-watching. Plain YAML scalars map to JSON types (booleans, numbers, null); quoted and block scalars stay strings. - **dotenv** — `FromDotEnv(path)` built into the core package (no dependency): `KEY=value`, `#` comments, optional `export` prefix, quotes, inline comments, and `:`/`__` key nesting; reactive. +- **Kubernetes ConfigMap / Secret support** — opt-in `followSymlinks` on `FromFile` / `FromYamlFile` / `FromDotEnv` (and `FileSourceProviderOptions.FollowSymlinks`). A ConfigMap-mounted file is a symlink whose content is updated by an atomic `..data` symlink swap; with this enabled the file is read (its resolved target must still stay within the configured directory — escaping symlinks are rejected) and the atomic swap is detected and hot-reloaded (via Cocoar.FileSystem 2.3.0 symlink-target tracking). Default off — symlinks remain rejected as before. ### Changed - Extracted a reusable `FileBackedProvider` base (watching, path/symlink security, debounce, disposal); `FileSourceProvider` is now a thin subclass (behavior unchanged). diff --git a/src/Cocoar.Configuration.Yaml/YamlRulesExtensions.cs b/src/Cocoar.Configuration.Yaml/YamlRulesExtensions.cs index 1b63919..5be144a 100644 --- a/src/Cocoar.Configuration.Yaml/YamlRulesExtensions.cs +++ b/src/Cocoar.Configuration.Yaml/YamlRulesExtensions.cs @@ -8,13 +8,15 @@ public static class YamlRulesExtensions { /// /// Creates a configuration rule that reads a YAML file (.yaml/.yml), watched for changes. + /// Set to read symlinked files and detect atomic symlink-target + /// swaps (e.g. Kubernetes ConfigMap mounts). /// public static ProviderRuleBuilder - FromYamlFile(this TypedProviderBuilder builder, string filePath) + FromYamlFile(this TypedProviderBuilder builder, string filePath, bool followSymlinks = false) where T : class => new( - cm => FileSourceRuleOptions.FromFilePath(filePath).ToProviderOptions(), - cm => FileSourceRuleOptions.FromFilePath(filePath).ToQueryOptions(), + cm => FileSourceRuleOptions.FromFilePath(filePath, followSymlinks: followSymlinks).ToProviderOptions(), + cm => FileSourceRuleOptions.FromFilePath(filePath, followSymlinks: followSymlinks).ToQueryOptions(), typeof(T) ); @@ -23,11 +25,11 @@ public static ProviderRuleBuildera => $"tenants/{a.Tenant}/config.yaml". The path is resolved from the accessor on each recompute. /// public static ProviderRuleBuilder - FromYamlFile(this TypedProviderBuilder builder, Func pathFactory) + FromYamlFile(this TypedProviderBuilder builder, Func pathFactory, bool followSymlinks = false) where T : class => new( - cm => FileSourceRuleOptions.FromFilePath(pathFactory(cm)).ToProviderOptions(), - cm => FileSourceRuleOptions.FromFilePath(pathFactory(cm)).ToQueryOptions(), + cm => FileSourceRuleOptions.FromFilePath(pathFactory(cm), followSymlinks: followSymlinks).ToProviderOptions(), + cm => FileSourceRuleOptions.FromFilePath(pathFactory(cm), followSymlinks: followSymlinks).ToQueryOptions(), typeof(T) ); } diff --git a/src/Cocoar.Configuration/Providers/DotEnvProvider/DotEnvRulesExtensions.cs b/src/Cocoar.Configuration/Providers/DotEnvProvider/DotEnvRulesExtensions.cs index 531e5ac..8a7ace1 100644 --- a/src/Cocoar.Configuration/Providers/DotEnvProvider/DotEnvRulesExtensions.cs +++ b/src/Cocoar.Configuration/Providers/DotEnvProvider/DotEnvRulesExtensions.cs @@ -7,13 +7,15 @@ public static class DotEnvRulesExtensions { /// /// Creates a configuration rule that reads a .env file (defaults to .env in the base directory). + /// Set to read symlinked files and detect atomic symlink-target + /// swaps (e.g. Kubernetes ConfigMap mounts). /// public static ProviderRuleBuilder - FromDotEnv(this TypedProviderBuilder builder, string filePath = ".env") + FromDotEnv(this TypedProviderBuilder builder, string filePath = ".env", bool followSymlinks = false) where T : class => new( - cm => FileSourceRuleOptions.FromFilePath(filePath).ToProviderOptions(), - cm => FileSourceRuleOptions.FromFilePath(filePath).ToQueryOptions(), + cm => FileSourceRuleOptions.FromFilePath(filePath, followSymlinks: followSymlinks).ToProviderOptions(), + cm => FileSourceRuleOptions.FromFilePath(filePath, followSymlinks: followSymlinks).ToQueryOptions(), typeof(T) ); @@ -22,11 +24,11 @@ public static ProviderRuleBuildera => $"tenants/{a.Tenant}/.env". The path is resolved from the accessor on each recompute. /// public static ProviderRuleBuilder - FromDotEnv(this TypedProviderBuilder builder, Func pathFactory) + FromDotEnv(this TypedProviderBuilder builder, Func pathFactory, bool followSymlinks = false) where T : class => new( - cm => FileSourceRuleOptions.FromFilePath(pathFactory(cm)).ToProviderOptions(), - cm => FileSourceRuleOptions.FromFilePath(pathFactory(cm)).ToQueryOptions(), + cm => FileSourceRuleOptions.FromFilePath(pathFactory(cm), followSymlinks: followSymlinks).ToProviderOptions(), + cm => FileSourceRuleOptions.FromFilePath(pathFactory(cm), followSymlinks: followSymlinks).ToQueryOptions(), typeof(T) ); } diff --git a/src/Cocoar.Configuration/Providers/FileSourceProvider/FileBackedProvider.cs b/src/Cocoar.Configuration/Providers/FileSourceProvider/FileBackedProvider.cs index 9ee68fc..02bb720 100644 --- a/src/Cocoar.Configuration/Providers/FileSourceProvider/FileBackedProvider.cs +++ b/src/Cocoar.Configuration/Providers/FileSourceProvider/FileBackedProvider.cs @@ -23,10 +23,20 @@ public abstract class FileBackedProvider protected FileBackedProvider(FileSourceProviderOptions options) : base(options) { - _monitor = ResilientFileSystemMonitor + var monitorBuilder = ResilientFileSystemMonitor .Watch(options.Directory, "*") - .WithPollingFallback(options.PollingInterval) - .Build(); + .WithPollingFallback(options.PollingInterval); + + if (options.FollowSymlinks) + { + // Kubernetes ConfigMap/Secret mounts update content by atomically swapping a sibling + // "..data" symlink — the watched file's name and metadata are unchanged. Tracking the + // resolved symlink target lets the monitor detect that swap and emit a change for the + // user-visible file. (Capability lives in Cocoar.FileSystem 2.3.0+.) + monitorBuilder = monitorBuilder.WithSymlinkTargetTracking(); + } + + _monitor = monitorBuilder.Build(); // Background task for event processing — observe faults so they don't go unnoticed var monitorTask = Task.Run(async () => await ProcessFileSystemEventsAsync(_cts.Token).ConfigureAwait(false)); @@ -144,12 +154,34 @@ private byte[] LoadRawFileBytes(string filename) $"If this file is created later at runtime, mark the rule as Optional.", fullPath); } - // Reject symlinks / reparse points to prevent symlink escape attacks + // Symlink / reparse-point handling. By default symlinks are rejected (defense in depth against + // symlink-escape). When FollowSymlinks is enabled (e.g. Kubernetes ConfigMap/Secret mounts, where + // every key is a symlink), the symlink is allowed only if its resolved final target stays within + // the configured directory — preserving the escape protection. var fileInfo = new FileInfo(fullPath); if ((fileInfo.Attributes & FileAttributes.ReparsePoint) != 0) { - throw new UnauthorizedAccessException( - $"Symlinks are not allowed for config files: {fullPath}"); + if (!ProviderOptions.FollowSymlinks) + { + throw new UnauthorizedAccessException( + $"Symlinks are not allowed for config files: {fullPath}. " + + $"Enable FollowSymlinks to read symlinked files (e.g. Kubernetes ConfigMap/Secret mounts)."); + } + + var finalTarget = fileInfo.ResolveLinkTarget(returnFinalTarget: true); + if (finalTarget is not null) + { + var resolvedFull = Path.GetFullPath(finalTarget.FullName); + if (!resolvedFull.StartsWith(baseDirWithSep, StringComparison.OrdinalIgnoreCase) + && !string.Equals(resolvedFull, baseDir, StringComparison.OrdinalIgnoreCase)) + { + throw new UnauthorizedAccessException( + $"Symlink target escapes the configured directory: '{filename}' resolves to " + + $"'{resolvedFull}', outside '{baseDir}'."); + } + } + // The OS re-resolves the link on the read below; a swap between this check and the read is + // only exploitable by something that can already write into the mount, which is out of scope. } // Use FileReader for secure file reading with shared access and BOM handling diff --git a/src/Cocoar.Configuration/Providers/FileSourceProvider/FileSourceProviderOptions.cs b/src/Cocoar.Configuration/Providers/FileSourceProvider/FileSourceProviderOptions.cs index cf86ac9..9cc40ab 100644 --- a/src/Cocoar.Configuration/Providers/FileSourceProvider/FileSourceProviderOptions.cs +++ b/src/Cocoar.Configuration/Providers/FileSourceProvider/FileSourceProviderOptions.cs @@ -2,7 +2,7 @@ namespace Cocoar.Configuration.Providers; -public class FileSourceProviderOptions(string directory, TimeSpan? pollingInterval = null) +public class FileSourceProviderOptions(string directory, TimeSpan? pollingInterval = null, bool followSymlinks = false) : IProviderConfiguration { public string Directory { get; } = @@ -10,6 +10,18 @@ public class FileSourceProviderOptions(string directory, TimeSpan? pollingInterv public TimeSpan PollingInterval { get; } = pollingInterval ?? TimeSpan.FromSeconds(10); + /// + /// When true, a symlinked config file is read (and its resolved target tracked for change + /// detection) rather than rejected. Required for Kubernetes ConfigMap/Secret volume mounts, which + /// expose each key as a symlink and update content by an atomic swap of a sibling ..data + /// symlink. The resolved final target must still resolve within ; a + /// symlink whose target escapes the directory is rejected. Default false — symlinks are + /// rejected as defense in depth against symlink-escape. + /// + public bool FollowSymlinks { get; } = followSymlinks; + + // FollowSymlinks is part of the key: a symlink-tracking monitor is configured differently from a + // plain one, so two rules that differ only by this flag must not share a provider instance. public string GenerateProviderKey() - => $"{Directory}|{PollingInterval.TotalMilliseconds}"; + => $"{Directory}|{PollingInterval.TotalMilliseconds}|{FollowSymlinks}"; } diff --git a/src/Cocoar.Configuration/Providers/FileSourceProvider/FileSourceRuleOptions.cs b/src/Cocoar.Configuration/Providers/FileSourceProvider/FileSourceRuleOptions.cs index 41183df..adb4f98 100644 --- a/src/Cocoar.Configuration/Providers/FileSourceProvider/FileSourceRuleOptions.cs +++ b/src/Cocoar.Configuration/Providers/FileSourceProvider/FileSourceRuleOptions.cs @@ -6,8 +6,9 @@ public sealed class FileSourceRuleOptions public string Filename { get; } public TimeSpan? DebounceTime { get; } public TimeSpan? PollingInterval { get; } - - public FileSourceRuleOptions(string directory, string filename, TimeSpan? debounceTime = null, TimeSpan? pollingInterval = null) + public bool FollowSymlinks { get; } + + public FileSourceRuleOptions(string directory, string filename, TimeSpan? debounceTime = null, TimeSpan? pollingInterval = null, bool followSymlinks = false) { if (string.IsNullOrWhiteSpace(directory)) { @@ -23,9 +24,10 @@ public FileSourceRuleOptions(string directory, string filename, TimeSpan? deboun Filename = filename; DebounceTime = debounceTime; PollingInterval = pollingInterval; + FollowSymlinks = followSymlinks; } - public static FileSourceRuleOptions FromFilePath(string filePath, TimeSpan? debounceTime = null, TimeSpan? pollingInterval = null) + public static FileSourceRuleOptions FromFilePath(string filePath, TimeSpan? debounceTime = null, TimeSpan? pollingInterval = null, bool followSymlinks = false) { if (string.IsNullOrWhiteSpace(filePath)) { @@ -43,9 +45,9 @@ public static FileSourceRuleOptions FromFilePath(string filePath, TimeSpan? debo throw new ArgumentException("filePath must include a filename", nameof(filePath)); } - return new(directory, filename, debounceTime, pollingInterval); + return new(directory, filename, debounceTime, pollingInterval, followSymlinks); } - public FileSourceProviderOptions ToProviderOptions() => new(Directory, PollingInterval); + public FileSourceProviderOptions ToProviderOptions() => new(Directory, PollingInterval, FollowSymlinks); public FileSourceProviderQueryOptions ToQueryOptions() => new(Filename, DebounceTime); } diff --git a/src/Cocoar.Configuration/Providers/FileSourceProvider/FileSourceRulesExtensions.cs b/src/Cocoar.Configuration/Providers/FileSourceProvider/FileSourceRulesExtensions.cs index 3323a38..be9716b 100644 --- a/src/Cocoar.Configuration/Providers/FileSourceProvider/FileSourceRulesExtensions.cs +++ b/src/Cocoar.Configuration/Providers/FileSourceProvider/FileSourceRulesExtensions.cs @@ -6,14 +6,15 @@ namespace Cocoar.Configuration.Providers; public static class FileSourceRulesExtensions { /// - /// Creates a file-based configuration rule from a file path. + /// Creates a file-based configuration rule from a file path. Set + /// to read symlinked files and detect atomic symlink-target swaps (e.g. Kubernetes ConfigMap mounts). /// public static ProviderRuleBuilder - FromFile(this TypedProviderBuilder builder, string filePath) + FromFile(this TypedProviderBuilder builder, string filePath, bool followSymlinks = false) where T : class => new( - cm => FileSourceRuleOptions.FromFilePath(filePath).ToProviderOptions(), - cm => FileSourceRuleOptions.FromFilePath(filePath).ToQueryOptions(), + cm => FileSourceRuleOptions.FromFilePath(filePath, followSymlinks: followSymlinks).ToProviderOptions(), + cm => FileSourceRuleOptions.FromFilePath(filePath, followSymlinks: followSymlinks).ToQueryOptions(), typeof(T) ); @@ -34,11 +35,11 @@ public static ProviderRuleBuildera => $"tenants/{a.Tenant}/db.json". The path is resolved from the accessor on each recompute. /// public static ProviderRuleBuilder - FromFile(this TypedProviderBuilder builder, Func pathFactory) + FromFile(this TypedProviderBuilder builder, Func pathFactory, bool followSymlinks = false) where T : class => new( - cm => FileSourceRuleOptions.FromFilePath(pathFactory(cm)).ToProviderOptions(), - cm => FileSourceRuleOptions.FromFilePath(pathFactory(cm)).ToQueryOptions(), + cm => FileSourceRuleOptions.FromFilePath(pathFactory(cm), followSymlinks: followSymlinks).ToProviderOptions(), + cm => FileSourceRuleOptions.FromFilePath(pathFactory(cm), followSymlinks: followSymlinks).ToQueryOptions(), typeof(T) ); } diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 676f588..c241324 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -4,7 +4,7 @@ - + diff --git a/src/tests/Cocoar.Configuration.Providers.Tests/File/FileProviderSecurityTests.cs b/src/tests/Cocoar.Configuration.Providers.Tests/File/FileProviderSecurityTests.cs index 66679f5..76026ff 100644 --- a/src/tests/Cocoar.Configuration.Providers.Tests/File/FileProviderSecurityTests.cs +++ b/src/tests/Cocoar.Configuration.Providers.Tests/File/FileProviderSecurityTests.cs @@ -193,6 +193,103 @@ public async Task Symlink_ToFileInsideDirectory_StillRejected() Assert.Contains("Symlinks are not allowed", ex.Message); } + // ────────────────────────────────────────────── + // S-02b: Symlink following (opt-in, FollowSymlinks) — e.g. Kubernetes ConfigMap mounts + // ────────────────────────────────────────────── + + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "FileSourceProvider")] + public async Task Symlink_ToFileInsideDirectory_WithFollowSymlinks_Succeeds() + { + if (!CanCreateSymlinks()) + { + _output.WriteLine("Skipping: symlink creation requires elevated privileges on this OS"); + return; + } + + using var tempDir = TempDirectoryHelper.Create(); + var realFile = Path.Combine(tempDir.Path, "real.json"); + System.IO.File.WriteAllText(realFile, """{"followed": true}"""); + + var symlinkPath = Path.Combine(tempDir.Path, "link.json"); + System.IO.File.CreateSymbolicLink(symlinkPath, realFile); + + var provider = new FileSourceProvider(new FileSourceProviderOptions(tempDir.Path, followSymlinks: true)); + var query = new FileSourceProviderQueryOptions("link.json"); + + var bytes = await provider.FetchConfigurationBytesAsync(query); + + Assert.Contains("followed", System.Text.Encoding.UTF8.GetString(bytes)); + _output.WriteLine("Inside-directory symlink read with FollowSymlinks enabled"); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "FileSourceProvider")] + public async Task Symlink_ToFileOutsideDirectory_WithFollowSymlinks_StillThrows() + { + // Even with FollowSymlinks on, a symlink whose target escapes the configured directory is rejected. + if (!CanCreateSymlinks()) + { + _output.WriteLine("Skipping: symlink creation requires elevated privileges on this OS"); + return; + } + + using var tempDir = TempDirectoryHelper.Create(); + using var outsideDir = TempDirectoryHelper.Create(); + + var outsideFile = Path.Combine(outsideDir.Path, "secret.json"); + System.IO.File.WriteAllText(outsideFile, """{"leaked": true}"""); + + var symlinkPath = Path.Combine(tempDir.Path, "linked.json"); + System.IO.File.CreateSymbolicLink(symlinkPath, outsideFile); + + var provider = new FileSourceProvider(new FileSourceProviderOptions(tempDir.Path, followSymlinks: true)); + var query = new FileSourceProviderQueryOptions("linked.json"); + + var ex = await Assert.ThrowsAsync( + () => provider.FetchConfigurationBytesAsync(query)); + + Assert.Contains("escapes the configured directory", ex.Message); + _output.WriteLine($"Escaping symlink still blocked with FollowSymlinks on: {ex.Message}"); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "FileSourceProvider")] + public async Task ConfigMapStyle_ChainedSymlink_WithFollowSymlinks_Succeeds() + { + // Mimics a Kubernetes ConfigMap layout: the user-visible file is a symlink that resolves through + // an intermediate *directory* symlink to the real file in a versioned data dir — all inside the + // mount. config.json -> data/config.json ; data -> data_v1 (dir) ; data_v1/config.json (real). + if (!CanCreateSymlinks()) + { + _output.WriteLine("Skipping: symlink creation requires elevated privileges on this OS"); + return; + } + + using var tempDir = TempDirectoryHelper.Create(); + + var dataVersionDir = Path.Combine(tempDir.Path, "data_v1"); + Directory.CreateDirectory(dataVersionDir); + System.IO.File.WriteAllText(Path.Combine(dataVersionDir, "config.json"), """{"source": "configmap"}"""); + + // Intermediate directory symlink (relative target), then the user-visible file symlink. + Directory.CreateSymbolicLink(Path.Combine(tempDir.Path, "data"), "data_v1"); + System.IO.File.CreateSymbolicLink( + Path.Combine(tempDir.Path, "config.json"), + Path.Combine("data", "config.json")); + + var provider = new FileSourceProvider(new FileSourceProviderOptions(tempDir.Path, followSymlinks: true)); + var query = new FileSourceProviderQueryOptions("config.json"); + + var bytes = await provider.FetchConfigurationBytesAsync(query); + + Assert.Contains("configmap", System.Text.Encoding.UTF8.GetString(bytes)); + _output.WriteLine("ConfigMap-style chained symlink read with FollowSymlinks enabled"); + } + private static bool CanCreateSymlinks() { var testDir = Path.Combine(Path.GetTempPath(), "cocoar_symlink_test_" + Guid.NewGuid().ToString("N")); diff --git a/website/changelog.md b/website/changelog.md index ac01736..7a62b56 100644 --- a/website/changelog.md +++ b/website/changelog.md @@ -5,6 +5,7 @@ ### Added - **`Cocoar.Configuration.Yaml`** — new opt-in YAML file provider (`FromYamlFile`) with reactive file-watching. Plain YAML scalars map to JSON types (`true`/`false` → boolean, integers/floats → number, `null`/`~` → null); quoted and block scalars stay strings. - **dotenv** — `FromDotEnv(path)` built into the core package (no dependency): `KEY=value` lines, `#` comments, optional `export` prefix, single/double quotes, inline comments, and `:`/`__` key nesting; reactive. +- **Kubernetes ConfigMap / Secret support** — opt-in `followSymlinks` parameter on `FromFile` / `FromYamlFile` / `FromDotEnv` (and `FileSourceProviderOptions.FollowSymlinks`). A ConfigMap-mounted file is a symlink whose content is updated by an atomic swap of the sibling `..data` symlink rather than by rewriting the file. With `followSymlinks` enabled, the symlinked file is read (its resolved final target must still resolve **within** the configured directory — an escaping symlink is rejected) and the atomic swap is detected and hot-reloaded (via Cocoar.FileSystem 2.3.0 symlink-target tracking). **Off by default** — symlinks remain rejected as defense in depth. ### Changed - Extracted a reusable `FileBackedProvider` base (watching, path/symlink security, debounce, disposal) so file-format providers share one implementation; `FileSourceProvider` is now a thin subclass (behavior unchanged). diff --git a/website/guide/providers/dotenv.md b/website/guide/providers/dotenv.md index 5a871fa..0fee1b8 100644 --- a/website/guide/providers/dotenv.md +++ b/website/guide/providers/dotenv.md @@ -4,7 +4,7 @@ description: "FromDotEnv provider (core, no dependency) — .env KEY=value parsi # Dotenv (.env) Provider -`FromDotEnv` reads a 12-factor-style `.env` file into the configuration pipeline. It is **built into the core package** (no extra dependency) and uses the same reactive file-watching as the [File provider](/guide/providers/file). +`FromDotEnv` reads a 12-factor-style `.env` file into the configuration pipeline. It is **built into the core package** (no extra dependency) and uses the same reactive file-watching as the [File provider](/guide/providers/file) — including `followSymlinks: true` for [Kubernetes ConfigMap / Secret mounts](/guide/providers/file#kubernetes-configmap-secret-mounts). ```csharp builder.AddCocoarConfiguration(c => c diff --git a/website/guide/providers/file.md b/website/guide/providers/file.md index 4a45bed..6fdc504 100644 --- a/website/guide/providers/file.md +++ b/website/guide/providers/file.md @@ -1,5 +1,5 @@ --- -description: FromFile JSON provider, directory file watcher, AppContext.BaseDirectory path resolution, debouncing, path-traversal protection, optional vs Required, dynamic paths +description: FromFile JSON provider, directory file watcher, AppContext.BaseDirectory path resolution, debouncing, path-traversal protection, Kubernetes ConfigMap symlink support (followSymlinks), optional vs Required, dynamic paths --- # File Provider @@ -43,9 +43,11 @@ File saves often trigger multiple file system events in rapid succession. The en The file provider includes path traversal protection: - Resolves the full path and validates it stays within the configured directory -- Rejects symlinks and reparse points to prevent symlink escape attacks +- **Rejects symlinks and reparse points by default** to prevent symlink-escape attacks - Throws `UnauthorizedAccessException` on violations +To read symlinked files — e.g. [Kubernetes ConfigMap / Secret mounts](#kubernetes-configmap-secret-mounts) — opt in with `followSymlinks`. Even then, a symlink whose resolved target escapes the configured directory is still rejected. + ## Advanced Options Use the factory overload for dynamic file paths or custom options: @@ -65,6 +67,28 @@ rule.For().FromFile(accessor => |---|---|---| | `DebounceTime` | None (uses engine default) | Per-file debounce for change events | | `PollingInterval` | 10 seconds | Fallback polling interval when file system events are unreliable | +| `FollowSymlinks` | `false` | Read symlinked files and detect atomic symlink-target swaps (see [Kubernetes ConfigMap / Secret mounts](#kubernetes-configmap-secret-mounts)) | + +## Kubernetes ConfigMap / Secret mounts + +A file mounted from a Kubernetes **ConfigMap** or **Secret** is a *symlink*: each key (e.g. `appsettings.json`) links through a sibling `..data` symlink to the real file in a timestamped directory. Kubernetes updates the volume by **atomically swapping the `..data` symlink** — it never rewrites the file you point at. + +Because symlinks are rejected by default, opt in with `followSymlinks: true`: + +```csharp +rule.For().FromFile("/etc/config/appsettings.json", followSymlinks: true) +``` + +This does two things: + +- **Reads** the symlinked file. The resolved final target must still resolve *within* the mount directory — an escaping symlink is rejected, so the path-traversal guarantees hold. +- **Hot-reloads** on the atomic `..data` swap, even though the watched file's name and timestamp are unchanged — the resolved symlink target is tracked for change detection. + +`followSymlinks` is available on `FromFile`, [`FromYamlFile`](/guide/providers/yaml), and [`FromDotEnv`](/guide/providers/dotenv) (and as `FollowSymlinks` on `FileSourceProviderOptions`). It is **off by default**, so non-Kubernetes deployments keep the stricter symlink rejection. + +::: tip Reload latency +The atomic `..data` swap does **not** trigger the instant OS file watcher — the file you point at is unchanged, only its symlink target moves. So a ConfigMap update is caught by the directory watcher's **periodic re-scan** (roughly every minute), plus kubelet's own propagation delay, rather than instantly. This interval is not currently tunable, and it's in line with how Kubernetes itself propagates ConfigMap changes (typically tens of seconds). Plan for **~1–2 minutes** end-to-end, not sub-second. +::: ## Missing Files diff --git a/website/guide/providers/yaml.md b/website/guide/providers/yaml.md index d42d448..9f46c06 100644 --- a/website/guide/providers/yaml.md +++ b/website/guide/providers/yaml.md @@ -4,7 +4,7 @@ description: "FromYamlFile provider (Cocoar.Configuration.Yaml) — reactive .ya # YAML Provider -`Cocoar.Configuration.Yaml` reads `.yaml` / `.yml` files into the configuration pipeline, with the same reactive file-watching, path resolution, and security as the [File provider](/guide/providers/file). Opt-in package (it takes a YamlDotNet dependency). +`Cocoar.Configuration.Yaml` reads `.yaml` / `.yml` files into the configuration pipeline, with the same reactive file-watching, path resolution, and security as the [File provider](/guide/providers/file) — including `followSymlinks: true` for [Kubernetes ConfigMap / Secret mounts](/guide/providers/file#kubernetes-configmap-secret-mounts). Opt-in package (it takes a YamlDotNet dependency). ```shell dotnet add package Cocoar.Configuration.Yaml From 100f756ad6bc7ed1ce0fb908e56b120c74d2f02a Mon Sep 17 00:00:00 2001 From: Bernhard Windisch Date: Tue, 2 Jun 2026 23:48:14 +0200 Subject: [PATCH 09/10] feat(providers): add TOML and INI file providers Two more file formats on the shared FileBackedProvider base, both supporting followSymlinks (Kubernetes ConfigMap) out of the box: - Cocoar.Configuration.Toml: new opt-in package (Tomlyn). TOML is strongly typed, so its values map unambiguously to JSON (no scalar- style guessing as in YAML); arrays-of-tables -> arrays of objects; dates -> ISO-8601 strings. FromTomlFile. +4 tests. - INI: built into core (no dependency). [section] headers, key=value, ;/# whole-line comments, ./: nesting, quote stripping. Inline comments are not stripped, so values with ;/# (e.g. connection strings) survive. FromIniFile. +5 tests. Docs (toml.md, ini.md, sidebar, packages.md) + changelog. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 2 + .../Cocoar.Configuration.Toml.csproj | 19 +++ .../TomlFileProvider.cs | 85 +++++++++++ .../TomlRulesExtensions.cs | 35 +++++ src/Cocoar.Configuration.slnx | 2 + .../Providers/IniProvider/IniProvider.cs | 96 +++++++++++++ .../IniProvider/IniRulesExtensions.cs | 34 +++++ src/Directory.Packages.props | 1 + .../Providers/IniProviderTests.cs | 136 ++++++++++++++++++ .../Cocoar.Configuration.Toml.Tests.csproj | 28 ++++ .../TomlFileProviderTests.cs | 134 +++++++++++++++++ website/.vitepress/config.ts | 2 + website/changelog.md | 2 + website/guide/providers/ini.md | 49 +++++++ website/guide/providers/toml.md | 50 +++++++ website/reference/packages.md | 12 ++ 16 files changed, 687 insertions(+) create mode 100644 src/Cocoar.Configuration.Toml/Cocoar.Configuration.Toml.csproj create mode 100644 src/Cocoar.Configuration.Toml/TomlFileProvider.cs create mode 100644 src/Cocoar.Configuration.Toml/TomlRulesExtensions.cs create mode 100644 src/Cocoar.Configuration/Providers/IniProvider/IniProvider.cs create mode 100644 src/Cocoar.Configuration/Providers/IniProvider/IniRulesExtensions.cs create mode 100644 src/tests/Cocoar.Configuration.Core.Tests/Providers/IniProviderTests.cs create mode 100644 src/tests/Cocoar.Configuration.Toml.Tests/Cocoar.Configuration.Toml.Tests.csproj create mode 100644 src/tests/Cocoar.Configuration.Toml.Tests/TomlFileProviderTests.cs create mode 100644 website/guide/providers/ini.md create mode 100644 website/guide/providers/toml.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 5208d47..fa40089 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,9 @@ ### Added - **`Cocoar.Configuration.Yaml`** — new opt-in YAML file provider (`FromYamlFile`) with reactive file-watching. Plain YAML scalars map to JSON types (booleans, numbers, null); quoted and block scalars stay strings. +- **`Cocoar.Configuration.Toml`** — new opt-in TOML file provider (`FromTomlFile`) with reactive file-watching (Tomlyn dependency). TOML's typed values (string/int/float/bool/datetime/array/table/array-of-tables) map unambiguously to JSON. - **dotenv** — `FromDotEnv(path)` built into the core package (no dependency): `KEY=value`, `#` comments, optional `export` prefix, quotes, inline comments, and `:`/`__` key nesting; reactive. +- **INI** — `FromIniFile(path)` built into the core package (no dependency): `[section]` headers, `key=value`, `;`/`#` whole-line comments, `.`/`:` nesting, quote stripping; values containing `;`/`#` (e.g. connection strings) are kept; reactive. - **Kubernetes ConfigMap / Secret support** — opt-in `followSymlinks` on `FromFile` / `FromYamlFile` / `FromDotEnv` (and `FileSourceProviderOptions.FollowSymlinks`). A ConfigMap-mounted file is a symlink whose content is updated by an atomic `..data` symlink swap; with this enabled the file is read (its resolved target must still stay within the configured directory — escaping symlinks are rejected) and the atomic swap is detected and hot-reloaded (via Cocoar.FileSystem 2.3.0 symlink-target tracking). Default off — symlinks remain rejected as before. ### Changed diff --git a/src/Cocoar.Configuration.Toml/Cocoar.Configuration.Toml.csproj b/src/Cocoar.Configuration.Toml/Cocoar.Configuration.Toml.csproj new file mode 100644 index 0000000..9b79142 --- /dev/null +++ b/src/Cocoar.Configuration.Toml/Cocoar.Configuration.Toml.csproj @@ -0,0 +1,19 @@ + + + + true + enable + enable + TOML file configuration provider for Cocoar.Configuration. Reads .toml files into the configuration pipeline with reactive file-watching, mapping TOML's typed values (strings, integers, floats, booleans, dates, arrays, tables) to JSON so they bind like JSON config. + configuration;toml;file;reactive;tomlyn + + + + + + + + + + + diff --git a/src/Cocoar.Configuration.Toml/TomlFileProvider.cs b/src/Cocoar.Configuration.Toml/TomlFileProvider.cs new file mode 100644 index 0000000..285f46c --- /dev/null +++ b/src/Cocoar.Configuration.Toml/TomlFileProvider.cs @@ -0,0 +1,85 @@ +using System.Globalization; +using System.Text; +using System.Text.Json.Nodes; +using Cocoar.Configuration.Providers; +using Tomlyn.Model; + +namespace Cocoar.Configuration.Toml; + +/// +/// Reads configuration from a TOML file. Converts TOML to the UTF-8 JSON the pipeline merges. TOML is +/// strongly typed, so the mapping is unambiguous: strings, integers, floats and booleans map to their JSON +/// equivalents; tables map to objects; arrays (and arrays-of-tables) map to JSON arrays; date/time values +/// map to ISO-8601 strings. Reactive: the file is watched and re-parsed on change (via ). +/// +public sealed class TomlFileProvider(FileSourceProviderOptions options) : FileBackedProvider(options) +{ + protected override byte[] ParseToJsonBytes(byte[] rawFileBytes, string filename) + { + var toml = Encoding.UTF8.GetString(rawFileBytes); + if (string.IsNullOrWhiteSpace(toml)) + { + return "{}"u8.ToArray(); + } + + // Deserialize into the dynamic TomlTable model. Throws TomlException on invalid TOML — the + // FileBackedProvider contract treats a throw on the fetch path as a hard failure (Required rollback / + // Optional degrade) and degrades to {} on the change path. + // global:: qualifier: this namespace ends in ".Toml", which would otherwise shadow the Tomlyn types. + var model = global::Tomlyn.TomlSerializer.Deserialize(toml); + return Encoding.UTF8.GetBytes(ConvertTable(model).ToJsonString()); + } + + private static JsonObject ConvertTable(TomlTable table) + { + var obj = new JsonObject(); + foreach (var entry in table) + { + obj[entry.Key] = ConvertValue(entry.Value); + } + + return obj; + } + + private static JsonNode? ConvertValue(object? value) => value switch + { + null => null, + TomlTable t => ConvertTable(t), + TomlTableArray ta => ConvertTableArray(ta), + TomlArray a => ConvertArray(a), + string s => JsonValue.Create(s), + bool b => JsonValue.Create(b), + long l => JsonValue.Create(l), + int i => JsonValue.Create((long)i), + double d => JsonValue.Create(d), + float f => JsonValue.Create((double)f), + // TOML date/time types — emit ISO-8601 strings; the binder coerces to DateTime/DateTimeOffset/etc. + DateTime dt => JsonValue.Create(dt.ToString("o", CultureInfo.InvariantCulture)), + DateTimeOffset dto => JsonValue.Create(dto.ToString("o", CultureInfo.InvariantCulture)), + DateOnly d => JsonValue.Create(d.ToString("o", CultureInfo.InvariantCulture)), + TimeOnly t => JsonValue.Create(t.ToString("o", CultureInfo.InvariantCulture)), + _ => JsonValue.Create(value.ToString()) + }; + + private static JsonArray ConvertArray(TomlArray array) + { + var arr = new JsonArray(); + foreach (var item in array) + { + arr.Add(ConvertValue(item)); + } + + return arr; + } + + private static JsonArray ConvertTableArray(TomlTableArray tableArray) + { + var arr = new JsonArray(); + foreach (var table in tableArray) + { + arr.Add(ConvertTable(table)); + } + + return arr; + } +} diff --git a/src/Cocoar.Configuration.Toml/TomlRulesExtensions.cs b/src/Cocoar.Configuration.Toml/TomlRulesExtensions.cs new file mode 100644 index 0000000..c780683 --- /dev/null +++ b/src/Cocoar.Configuration.Toml/TomlRulesExtensions.cs @@ -0,0 +1,35 @@ +using Cocoar.Configuration.Core; +using Cocoar.Configuration.Fluent; +using Cocoar.Configuration.Providers; + +namespace Cocoar.Configuration.Toml; + +public static class TomlRulesExtensions +{ + /// + /// Creates a configuration rule that reads a .toml file, watched for changes. Set + /// to read symlinked files and detect atomic symlink-target swaps + /// (e.g. Kubernetes ConfigMap mounts). + /// + public static ProviderRuleBuilder + FromTomlFile(this TypedProviderBuilder builder, string filePath, bool followSymlinks = false) + where T : class + => new( + cm => FileSourceRuleOptions.FromFilePath(filePath, followSymlinks: followSymlinks).ToProviderOptions(), + cm => FileSourceRuleOptions.FromFilePath(filePath, followSymlinks: followSymlinks).ToQueryOptions(), + typeof(T) + ); + + /// + /// Creates a TOML rule from a config-aware path — e.g. a per-tenant path + /// a => $"tenants/{a.Tenant}/config.toml". The path is resolved from the accessor on each recompute. + /// + public static ProviderRuleBuilder + FromTomlFile(this TypedProviderBuilder builder, Func pathFactory, bool followSymlinks = false) + where T : class + => new( + cm => FileSourceRuleOptions.FromFilePath(pathFactory(cm), followSymlinks: followSymlinks).ToProviderOptions(), + cm => FileSourceRuleOptions.FromFilePath(pathFactory(cm), followSymlinks: followSymlinks).ToQueryOptions(), + typeof(T) + ); +} diff --git a/src/Cocoar.Configuration.slnx b/src/Cocoar.Configuration.slnx index 863d499..57d5aaf 100644 --- a/src/Cocoar.Configuration.slnx +++ b/src/Cocoar.Configuration.slnx @@ -15,6 +15,7 @@ + @@ -46,4 +47,5 @@ + diff --git a/src/Cocoar.Configuration/Providers/IniProvider/IniProvider.cs b/src/Cocoar.Configuration/Providers/IniProvider/IniProvider.cs new file mode 100644 index 0000000..38c1ca5 --- /dev/null +++ b/src/Cocoar.Configuration/Providers/IniProvider/IniProvider.cs @@ -0,0 +1,96 @@ +using System.Text; +using System.Text.Json; + +namespace Cocoar.Configuration.Providers; + +/// +/// Reads configuration from an .ini file: [section] headers, key=value lines, and +/// whole-line comments (; or #). Section and key names nest with . or : (e.g. +/// [Db] + Port=5432{ "Db": { "Port": "5432" } }; [Db.Primary] nests further). +/// Values are emitted as JSON strings; the binder coerces them to the target type. Surrounding quotes are +/// stripped. Inline comments are not stripped, so values containing ; or # (e.g. a +/// connection string) survive intact. Reactive: the file is watched and re-parsed on change (via +/// ). +/// +public sealed class IniProvider(FileSourceProviderOptions options) : FileBackedProvider(options) +{ + protected override byte[] ParseToJsonBytes(byte[] rawFileBytes, string filename) + { + var text = Encoding.UTF8.GetString(rawFileBytes); + var root = new Dictionary(StringComparer.OrdinalIgnoreCase); + var section = Array.Empty(); + + foreach (var rawLine in text.Split('\n')) + { + var line = rawLine.Trim(); + if (line.Length == 0 || line[0] == ';' || line[0] == '#') + { + continue; // blank or whole-line comment + } + + if (line[0] == '[' && line[^1] == ']') + { + var name = line[1..^1].Trim(); + section = name.Length == 0 ? Array.Empty() : SplitPath(name); + continue; + } + + var eq = line.IndexOf('='); + if (eq <= 0) + { + continue; // no key, or no '=' + } + + var key = line[..eq].Trim(); + if (key.Length == 0) + { + continue; + } + + var path = section.Length == 0 ? SplitPath(key) : [.. section, .. SplitPath(key)]; + if (path.Length == 0) + { + continue; + } + + AddNested(root, path, Unquote(line[(eq + 1)..].Trim())); + } + + return JsonSerializer.SerializeToUtf8Bytes(root); + } + + private static string[] SplitPath(string name) => + name.Split([':', '.'], StringSplitOptions.RemoveEmptyEntries); + + private static string Unquote(string value) + { + if (value.Length >= 2) + { + var quote = value[0]; + if ((quote == '"' || quote == '\'') && value[^1] == quote) + { + return value[1..^1]; + } + } + + // No inline-comment stripping: an unquoted value keeps any ';' or '#' (e.g. a connection string). + return value; + } + + private static void AddNested(Dictionary root, string[] path, string value) + { + var current = root; + for (var i = 0; i < path.Length - 1; i++) + { + if (!current.TryGetValue(path[i], out var next) || next is not Dictionary dict) + { + dict = new Dictionary(StringComparer.OrdinalIgnoreCase); + current[path[i]] = dict; + } + + current = dict; + } + + current[path[^1]] = value; + } +} diff --git a/src/Cocoar.Configuration/Providers/IniProvider/IniRulesExtensions.cs b/src/Cocoar.Configuration/Providers/IniProvider/IniRulesExtensions.cs new file mode 100644 index 0000000..008e71e --- /dev/null +++ b/src/Cocoar.Configuration/Providers/IniProvider/IniRulesExtensions.cs @@ -0,0 +1,34 @@ +using Cocoar.Configuration.Core; +using Cocoar.Configuration.Fluent; + +namespace Cocoar.Configuration.Providers; + +public static class IniRulesExtensions +{ + /// + /// Creates a configuration rule that reads an .ini file, watched for changes. Set + /// to read symlinked files and detect atomic symlink-target swaps + /// (e.g. Kubernetes ConfigMap mounts). + /// + public static ProviderRuleBuilder + FromIniFile(this TypedProviderBuilder builder, string filePath, bool followSymlinks = false) + where T : class + => new( + cm => FileSourceRuleOptions.FromFilePath(filePath, followSymlinks: followSymlinks).ToProviderOptions(), + cm => FileSourceRuleOptions.FromFilePath(filePath, followSymlinks: followSymlinks).ToQueryOptions(), + typeof(T) + ); + + /// + /// Creates an .ini rule from a config-aware path — e.g. a per-tenant path + /// a => $"tenants/{a.Tenant}/config.ini". The path is resolved from the accessor on each recompute. + /// + public static ProviderRuleBuilder + FromIniFile(this TypedProviderBuilder builder, Func pathFactory, bool followSymlinks = false) + where T : class + => new( + cm => FileSourceRuleOptions.FromFilePath(pathFactory(cm), followSymlinks: followSymlinks).ToProviderOptions(), + cm => FileSourceRuleOptions.FromFilePath(pathFactory(cm), followSymlinks: followSymlinks).ToQueryOptions(), + typeof(T) + ); +} diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index c241324..c4e0bf5 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -7,6 +7,7 @@ + all diff --git a/src/tests/Cocoar.Configuration.Core.Tests/Providers/IniProviderTests.cs b/src/tests/Cocoar.Configuration.Core.Tests/Providers/IniProviderTests.cs new file mode 100644 index 0000000..b92d00f --- /dev/null +++ b/src/tests/Cocoar.Configuration.Core.Tests/Providers/IniProviderTests.cs @@ -0,0 +1,136 @@ +using System.Text; +using System.Text.Json; +using Cocoar.Configuration.Core; +using Cocoar.Configuration.Providers; + +namespace Cocoar.Configuration.Core.Tests.Providers; + +public class IniProviderTests : IDisposable +{ + private readonly DirectoryInfo _dir = Directory.CreateTempSubdirectory("cocoar-ini-"); + + private JsonElement Parse(string content, string filename = "config.ini") + { + File.WriteAllText(Path.Combine(_dir.FullName, filename), content); + var provider = new IniProvider(new FileSourceProviderOptions(_dir.FullName)); + var bytes = provider.FetchConfigurationBytesAsync(new FileSourceProviderQueryOptions(filename)) + .GetAwaiter().GetResult(); + return JsonDocument.Parse(Encoding.UTF8.GetString(bytes)).RootElement; + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "IniProvider")] + public void Sections_nest_and_root_keys_stay_at_root() + { + var json = Parse( + """ + app = myapp + + [db] + host = localhost + port = 5432 + """); + + Assert.Equal("myapp", json.GetProperty("app").GetString()); + Assert.Equal("localhost", json.GetProperty("db").GetProperty("host").GetString()); + Assert.Equal("5432", json.GetProperty("db").GetProperty("port").GetString()); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "IniProvider")] + public void Whole_line_comments_and_blanks_are_ignored() + { + var json = Parse( + """ + ; semicolon comment + # hash comment + + real = 1 + """); + + Assert.Equal("1", json.GetProperty("real").GetString()); + Assert.Equal(1, json.EnumerateObject().Count()); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "IniProvider")] + public void Nested_section_names_split_on_dot_and_colon() + { + var json = Parse( + """ + [Db.Primary] + host = a + + [Db:Replica] + host = b + """); + + Assert.Equal("a", json.GetProperty("Db").GetProperty("Primary").GetProperty("host").GetString()); + Assert.Equal("b", json.GetProperty("Db").GetProperty("Replica").GetProperty("host").GetString()); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "IniProvider")] + public void Quotes_are_stripped_and_inline_comment_chars_are_preserved() + { + var json = Parse( + """ + [db] + quoted = "hello world" + conn = Server=db;Database=app;Trusted_Connection=true + hash = a#b + """); + + Assert.Equal("hello world", json.GetProperty("db").GetProperty("quoted").GetString()); + // A ';'/'#' inside a value must survive — no inline-comment stripping (connection-string safety). + Assert.Equal("Server=db;Database=app;Trusted_Connection=true", json.GetProperty("db").GetProperty("conn").GetString()); + Assert.Equal("a#b", json.GetProperty("db").GetProperty("hash").GetString()); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "IniProvider")] + public void Binds_through_ConfigManager_via_FromIniFile() + { + File.WriteAllText(Path.Combine(_dir.FullName, "app.ini"), + """ + Name = myapp + + [Db] + Port = 5432 + Host = localhost + """); + + using var manager = ConfigManager.Create(c => c + .UseConfiguration(rules => + [ + rules.For().FromIniFile(Path.Combine(_dir.FullName, "app.ini")) + ])); + + var cfg = manager.GetConfig()!; + Assert.Equal("myapp", cfg.Name); + Assert.Equal(5432, cfg.Db.Port); + Assert.Equal("localhost", cfg.Db.Host); + } + + public sealed class AppCfg + { + public string? Name { get; set; } + public DbCfg Db { get; set; } = new(); + } + + public sealed class DbCfg + { + public int Port { get; set; } + public string? Host { get; set; } + } + + public void Dispose() + { + try { _dir.Delete(recursive: true); } catch { /* best-effort temp cleanup */ } + } +} diff --git a/src/tests/Cocoar.Configuration.Toml.Tests/Cocoar.Configuration.Toml.Tests.csproj b/src/tests/Cocoar.Configuration.Toml.Tests/Cocoar.Configuration.Toml.Tests.csproj new file mode 100644 index 0000000..c8f9caa --- /dev/null +++ b/src/tests/Cocoar.Configuration.Toml.Tests/Cocoar.Configuration.Toml.Tests.csproj @@ -0,0 +1,28 @@ + + + + net10.0 + enable + enable + true + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + diff --git a/src/tests/Cocoar.Configuration.Toml.Tests/TomlFileProviderTests.cs b/src/tests/Cocoar.Configuration.Toml.Tests/TomlFileProviderTests.cs new file mode 100644 index 0000000..c2c9645 --- /dev/null +++ b/src/tests/Cocoar.Configuration.Toml.Tests/TomlFileProviderTests.cs @@ -0,0 +1,134 @@ +using System.Text; +using System.Text.Json; +using Cocoar.Configuration.Core; +using Cocoar.Configuration.Providers; +using Cocoar.Configuration.Toml; + +namespace Cocoar.Configuration.Toml.Tests; + +public class TomlFileProviderTests : IDisposable +{ + private readonly DirectoryInfo _dir = Directory.CreateTempSubdirectory("cocoar-toml-"); + + private JsonElement Parse(string toml, string filename = "config.toml") + { + File.WriteAllText(Path.Combine(_dir.FullName, filename), toml); + var provider = new TomlFileProvider(new FileSourceProviderOptions(_dir.FullName)); + var bytes = provider.FetchConfigurationBytesAsync(new FileSourceProviderQueryOptions(filename)) + .GetAwaiter().GetResult(); + return JsonDocument.Parse(Encoding.UTF8.GetString(bytes)).RootElement; + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "TomlFileProvider")] + public void Maps_typed_scalars_to_json_types() + { + var json = Parse( + """ + name = "hello" + enabled = true + disabled = false + port = 5432 + ratio = 1.5 + """); + + Assert.Equal("hello", json.GetProperty("name").GetString()); + Assert.Equal(JsonValueKind.True, json.GetProperty("enabled").ValueKind); + Assert.Equal(JsonValueKind.False, json.GetProperty("disabled").ValueKind); + Assert.Equal(5432, json.GetProperty("port").GetInt32()); + Assert.Equal(1.5, json.GetProperty("ratio").GetDouble()); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "TomlFileProvider")] + public void Maps_tables_arrays_and_arrays_of_tables() + { + var json = Parse( + """ + hosts = ["a", "b", "c"] + + [db] + host = "localhost" + port = 5432 + + [[servers]] + name = "s1" + + [[servers]] + name = "s2" + """); + + Assert.Equal("localhost", json.GetProperty("db").GetProperty("host").GetString()); + Assert.Equal(5432, json.GetProperty("db").GetProperty("port").GetInt32()); + + var hosts = json.GetProperty("hosts"); + Assert.Equal(JsonValueKind.Array, hosts.ValueKind); + Assert.Equal(3, hosts.GetArrayLength()); + Assert.Equal("b", hosts[1].GetString()); + + var servers = json.GetProperty("servers"); + Assert.Equal(JsonValueKind.Array, servers.ValueKind); + Assert.Equal(2, servers.GetArrayLength()); + Assert.Equal("s2", servers[1].GetProperty("name").GetString()); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "TomlFileProvider")] + public void Empty_file_yields_empty_object() + { + Assert.Equal(JsonValueKind.Object, Parse("").ValueKind); + Assert.Empty(Parse("").EnumerateObject()); + } + + [Fact] + [Trait("Type", "Unit")] + [Trait("Provider", "TomlFileProvider")] + public void Binds_through_ConfigManager_via_FromTomlFile() + { + File.WriteAllText(Path.Combine(_dir.FullName, "app.toml"), + """ + name = "myapp" + enabled = true + ratio = 1.5 + + [db] + port = 5432 + hosts = ["h1", "h2"] + """); + + using var manager = ConfigManager.Create(c => c + .UseConfiguration(rules => + [ + rules.For().FromTomlFile(Path.Combine(_dir.FullName, "app.toml")) + ])); + + var cfg = manager.GetConfig()!; + Assert.Equal("myapp", cfg.Name); + Assert.True(cfg.Enabled); + Assert.Equal(1.5, cfg.Ratio); + Assert.Equal(5432, cfg.Db.Port); + Assert.Equal(new[] { "h1", "h2" }, cfg.Db.Hosts); + } + + public sealed class AppCfg + { + public string? Name { get; set; } + public bool Enabled { get; set; } + public double Ratio { get; set; } + public DbCfg Db { get; set; } = new(); + } + + public sealed class DbCfg + { + public int Port { get; set; } + public List Hosts { get; set; } = new(); + } + + public void Dispose() + { + try { _dir.Delete(recursive: true); } catch { /* best-effort temp cleanup */ } + } +} diff --git a/website/.vitepress/config.ts b/website/.vitepress/config.ts index 82025ed..994885c 100644 --- a/website/.vitepress/config.ts +++ b/website/.vitepress/config.ts @@ -63,7 +63,9 @@ export default defineConfig({ { text: 'Overview', link: '/guide/providers/overview' }, { text: 'File', link: '/guide/providers/file' }, { text: 'YAML', link: '/guide/providers/yaml' }, + { text: 'TOML', link: '/guide/providers/toml' }, { text: 'Dotenv (.env)', link: '/guide/providers/dotenv' }, + { text: 'INI', link: '/guide/providers/ini' }, { text: 'Environment Variables', link: '/guide/providers/environment' }, { text: 'Command Line', link: '/guide/providers/command-line' }, { text: 'HTTP Polling', link: '/guide/providers/http-polling' }, diff --git a/website/changelog.md b/website/changelog.md index 7a62b56..ea99331 100644 --- a/website/changelog.md +++ b/website/changelog.md @@ -4,7 +4,9 @@ ### Added - **`Cocoar.Configuration.Yaml`** — new opt-in YAML file provider (`FromYamlFile`) with reactive file-watching. Plain YAML scalars map to JSON types (`true`/`false` → boolean, integers/floats → number, `null`/`~` → null); quoted and block scalars stay strings. +- **`Cocoar.Configuration.Toml`** — new opt-in TOML file provider (`FromTomlFile`) with reactive file-watching (takes a Tomlyn dependency). TOML is strongly typed, so its values (strings, integers, floats, booleans, dates, arrays, tables, arrays-of-tables) map unambiguously to JSON — no scalar-style guessing as in YAML. Date/time values are emitted as ISO-8601 strings. - **dotenv** — `FromDotEnv(path)` built into the core package (no dependency): `KEY=value` lines, `#` comments, optional `export` prefix, single/double quotes, inline comments, and `:`/`__` key nesting; reactive. +- **INI** — `FromIniFile(path)` built into the **core** package (no dependency): classic `[section]` headers, `key=value` lines, `;`/`#` whole-line comments, `.`/`:` nesting (matching the environment-variable convention), and quote stripping. Inline comments are **not** stripped, so values containing `;`/`#` (e.g. connection strings) survive intact. Values are strings; the binder coerces. Reactive. - **Kubernetes ConfigMap / Secret support** — opt-in `followSymlinks` parameter on `FromFile` / `FromYamlFile` / `FromDotEnv` (and `FileSourceProviderOptions.FollowSymlinks`). A ConfigMap-mounted file is a symlink whose content is updated by an atomic swap of the sibling `..data` symlink rather than by rewriting the file. With `followSymlinks` enabled, the symlinked file is read (its resolved final target must still resolve **within** the configured directory — an escaping symlink is rejected) and the atomic swap is detected and hot-reloaded (via Cocoar.FileSystem 2.3.0 symlink-target tracking). **Off by default** — symlinks remain rejected as defense in depth. ### Changed diff --git a/website/guide/providers/ini.md b/website/guide/providers/ini.md new file mode 100644 index 0000000..61d3c79 --- /dev/null +++ b/website/guide/providers/ini.md @@ -0,0 +1,49 @@ +--- +description: "FromIniFile provider (core, no dependency) — .ini [section] headers, key=value, ;/# whole-line comments, :/. nesting, quote stripping, connection-string-safe (no inline-comment stripping), reactive watching" +--- + +# INI Provider + +`FromIniFile` reads a classic `.ini` file into the configuration pipeline. It is **built into the core package** (no extra dependency) and uses the same reactive file-watching as the [File provider](/guide/providers/file) — including `followSymlinks: true` for [Kubernetes ConfigMap / Secret mounts](/guide/providers/file#kubernetes-configmap-secret-mounts). + +```csharp +builder.AddCocoarConfiguration(c => c + .UseConfiguration(rules => + [ + rules.For().FromIniFile("appsettings.ini"), + ])); +``` + +## Format + +```ini +; whole-line comments start with ; or # +# both are ignored + +app = myapp ; keys before any [section] sit at the root + +[Db] +Host = localhost +Port = 5432 ; values are strings; the binder coerces (→ int) +Conn = Server=db;Database=app ; ';' / '#' inside a value are kept + +[Db.Primary] ; section names nest on '.' or ':' +Weight = 1 +``` + +- `[Section]` headers and keys nest on `.` or `:` (e.g. `[Db.Primary]` → `{ "Db": { "Primary": { … } } }`), matching the [Environment Variables provider](/guide/providers/environment) convention. +- Surrounding single/double quotes are stripped. +- **Whole-line comments only** (a line starting with `;` or `#`). Inline comments are *not* stripped, so a value containing `;` or `#` — like a connection string — survives intact. +- Values are emitted as strings; the binder coerces them to the target type. + +## Reactivity & per-tenant paths + +Editing the file triggers a recompute. A config-aware overload resolves the path per recompute: + +```csharp +rules.For().FromIniFile(a => $"tenants/{a.Tenant}/db.ini").TenantScoped() +``` + +## Other formats + +For `.toml` see the [TOML provider](/guide/providers/toml); `.yaml` / `.yml` → [YAML](/guide/providers/yaml); `.env` → [Dotenv](/guide/providers/dotenv). diff --git a/website/guide/providers/toml.md b/website/guide/providers/toml.md new file mode 100644 index 0000000..a0d9d42 --- /dev/null +++ b/website/guide/providers/toml.md @@ -0,0 +1,50 @@ +--- +description: "FromTomlFile provider (Cocoar.Configuration.Toml) — reactive .toml watching, TOML typed values (string/int/float/bool/datetime/array/table) mapped to JSON, arrays-of-tables, Kubernetes ConfigMap support" +--- + +# TOML Provider + +`Cocoar.Configuration.Toml` reads `.toml` files into the configuration pipeline, with the same reactive file-watching, path resolution, and security as the [File provider](/guide/providers/file) — including `followSymlinks: true` for [Kubernetes ConfigMap / Secret mounts](/guide/providers/file#kubernetes-configmap-secret-mounts). Opt-in package (it takes a Tomlyn dependency). + +```shell +dotnet add package Cocoar.Configuration.Toml +``` + +```csharp +using Cocoar.Configuration.Toml; + +builder.AddCocoarConfiguration(c => c + .UseConfiguration(rules => + [ + rules.For().FromTomlFile("appsettings.toml"), + ])); +``` + +## Typed values + +TOML is strongly typed, so the mapping to JSON is unambiguous — no scalar-style guessing as in YAML: + +| TOML | Binds as | +|---|---| +| `name = "hello"` | string | +| `enabled = true` | boolean | +| `port = 5432` | number | +| `ratio = 1.5` | number | +| `created = 1979-05-27T07:32:00Z` | string (ISO-8601) | +| `hosts = ["a", "b"]` | array | +| `[db]` (table) | object | +| `[[servers]]` (array of tables) | array of objects | + +Date/time values are emitted as ISO-8601 strings; the binder coerces them to `DateTime` / `DateTimeOffset` as needed. + +## Reactivity & per-tenant paths + +Editing the file triggers a recompute (same watcher as `FromFile`). A config-aware overload resolves the path per recompute — e.g. per tenant: + +```csharp +rules.For().FromTomlFile(a => $"tenants/{a.Tenant}/config.toml").TenantScoped() +``` + +## Other formats + +For `.yaml` / `.yml` see the [YAML provider](/guide/providers/yaml); for `.env` see [Dotenv](/guide/providers/dotenv); for `.ini` see the [INI provider](/guide/providers/ini). diff --git a/website/reference/packages.md b/website/reference/packages.md index 5c56e54..4707721 100644 --- a/website/reference/packages.md +++ b/website/reference/packages.md @@ -106,6 +106,18 @@ YAML file provider. Reads `.yaml`/`.yml` files into the configuration pipeline w ``` +### Cocoar.Configuration.Toml + +TOML file provider. Reads `.toml` files into the configuration pipeline with reactive file-watching. TOML's typed values (strings, integers, floats, booleans, dates, arrays, tables, arrays-of-tables) map unambiguously to JSON so they bind like JSON. Opt-in package — it takes a Tomlyn dependency. (The `.ini` and `.env` providers are built into the core package and need no dependency.) + +- **Target:** .NET 9.0 / .NET 10.0 +- **Dependencies:** Cocoar.Configuration, Tomlyn +- **Key types:** `TomlFileProvider`, `FromTomlFile()` extension method + +```xml + +``` + ### Cocoar.Configuration.Analyzers Roslyn analyzers (COCFG001–006) and source generator (COCFLAG001–003). Ships as a build-time dependency of the core package — you don't need to install it separately. From 9210901bc411dc4c7a9e602f27bc25f692029937 Mon Sep 17 00:00:00 2001 From: Bernhard Windisch Date: Wed, 3 Jun 2026 00:45:34 +0200 Subject: [PATCH 10/10] docs(changelog): stamp [6.1.0] Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 2 +- website/changelog.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fa40089..f9c9617 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [6.1.0] - 2026-06-03 ### Added - **`Cocoar.Configuration.Yaml`** — new opt-in YAML file provider (`FromYamlFile`) with reactive file-watching. Plain YAML scalars map to JSON types (booleans, numbers, null); quoted and block scalars stay strings. diff --git a/website/changelog.md b/website/changelog.md index ea99331..f544589 100644 --- a/website/changelog.md +++ b/website/changelog.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [6.1.0] — 2026-06-03 ### Added - **`Cocoar.Configuration.Yaml`** — new opt-in YAML file provider (`FromYamlFile`) with reactive file-watching. Plain YAML scalars map to JSON types (`true`/`false` → boolean, integers/floats → number, `null`/`~` → null); quoted and block scalars stay strings.