diff --git a/CHANGELOG.md b/CHANGELOG.md index 678e684..da5339e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,27 @@ ## [Unreleased] +### Added + +- **LocalStorage provider** — a writable, application-controlled override layer for *overridable defaults*: the normal sources (files, environment, …) supply defaults, and the application overrides individual values at runtime. + - `ILocalStorage` (type-safe facade) and `ILocalStorageOverlay` (raw key-path surface) in `Cocoar.Configuration.Abstractions` + - **Sparse writes** — `SetAsync(x => x.Smtp.Port, value)` persists only the touched leaf; unset keys keep inheriting from the lower layers + - `ResetAsync(...)` removes an override (value falls back to the inherited default); an explicit `null` override is distinct from reset + - `DescribeAsync()` returns per-key provenance (`OverrideEntry`: base value, effective value, `IsOverridden`) for management UIs + - `.FromLocalStorage()` rule extension; file-based backend by default with a pluggable `IStorageBackend` + - `ILocalStorage` / `ILocalStorageOverlay` are DI-injectable (single shared singleton) — write your own endpoints with your own validation/normalization/logging + - LocalStorageOverride example project +- `IProviderServiceRegistration` now supports resolve-time factory registrations (`ProviderServiceRegistration.Singleton(type, factory)`) in addition to eager instances + ### Changed - Secret payloads (the decrypted value of `Secret`) now (de)serialize with lenient options: **enums as names** (round-trip-safe if the enum is later reordered) and **case-insensitive** property matching. Reading still accepts numeric enums and any casing, so **existing encrypted secrets remain fully readable** — no migration. Only the in-memory form of newly serialized typed secret values changes (enum name instead of ordinal); encrypted envelopes at rest are unaffected. +### Notes + +- Secret-typed members (`Secret` / `ISecret`) cannot be overridden via LocalStorage — the typed facade throws `NotSupportedException` (manage secrets via the Secrets CLI/provider). +- Overlay values serialize with vanilla options (enums as strings) and overlay keys are aligned to the lower layers' casing, so an override **replaces** the base key rather than creating a casing-variant sibling. + ## [5.0.0] - 2026-03-24 ### Added diff --git a/README.md b/README.md index 3888deb..f055cdb 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,7 @@ app.Run(); | Environment Variables | `.FromEnvironment("PREFIX_")` | Core | | Command Line | `.FromCommandLine("--prefix")` | Core | | Static / Observable | `.FromStaticJson()` / `.FromObservable()` | Core | +| LocalStorage (writable overlay) | `.FromLocalStorage()` | Core | | HTTP | `.FromHttp(url)` | [Http](https://www.nuget.org/packages/Cocoar.Configuration.Http) | | Microsoft IConfiguration | `.FromIConfiguration(config)` | [MicrosoftAdapter](https://www.nuget.org/packages/Cocoar.Configuration.MicrosoftAdapter) | diff --git a/src/Cocoar.Configuration.Abstractions/LocalStorage/ILocalStorage.cs b/src/Cocoar.Configuration.Abstractions/LocalStorage/ILocalStorage.cs new file mode 100644 index 0000000..fa89a4f --- /dev/null +++ b/src/Cocoar.Configuration.Abstractions/LocalStorage/ILocalStorage.cs @@ -0,0 +1,87 @@ +using System.Linq.Expressions; +using System.Text.Json; +using Cocoar.Configuration.Secrets.SecretTypes; + +namespace Cocoar.Configuration.LocalStorage; + +/// +/// Type-safe facade for a LocalStorage override layer over configuration type . +/// +/// LocalStorage supplies overridable defaults: the normal sources (files, environment, …) provide +/// defaults, and the application overrides individual values at runtime. Writes are sparse — only +/// the keys you set are persisted, everything else continues to inherit from the lower layers. A write +/// triggers the normal recompute, so IReactiveConfig<T> emits the new effective value. +/// +/// +/// Secret-typed members (Secret<T> / ISecret<T>) cannot be overridden through this +/// API and throw ; manage secrets via the Secrets CLI/provider. +/// +/// +/// The configuration type this overlay targets. +public interface ILocalStorage where T : class +{ + /// + /// Overrides a single value selected by a member-access expression (e.g. x => x.Smtp.Port), + /// persisting only that leaf. The member chain is translated into a dotted key path and the value is + /// serialized with vanilla options (enums as strings); the lower layers are not otherwise touched. + /// + /// + /// The selector is not a simple member-access chain (e.g. it contains an indexer, method call, or cast), + /// or it targets a secret-typed member. + /// + Task SetAsync(Expression> selector, TValue value, CancellationToken ct = default); + + /// + /// Sets a pre-encrypted secret envelope for a secret-typed member (e.g. x => x.ApiKey), + /// encrypted client-side with the server's public certificate so plaintext never reaches the server. + /// The selector must point at a Secret<TSecret> / ISecret<TSecret> member, and the + /// must carry the same — so the envelope and + /// the target secret are matched at compile time. The normal still rejects + /// secret members (and objects containing secrets) to prevent storing plaintext. + /// + /// The envelope is not a well-formed cocoar.secret envelope. + Task SetSecretAsync(Expression>> selector, SecretEnvelope envelope, CancellationToken ct = default); + + /// + /// Resets a single value to its inherited (lower-layer) value by removing that leaf from the overlay. + /// + /// if an override was removed; if none existed. + Task ResetAsync(Expression> selector, CancellationToken ct = default); + + /// + /// Resets everything this layer overrides, so all keys inherit from the lower layers again. + /// + Task ClearAsync(CancellationToken ct = default); + + /// + /// Reads the sparse overlay into a partial where unset properties take their C# + /// defaults. Returns when nothing is overridden. For the merged/effective value, + /// use IReactiveConfig<T>.CurrentValue or IConfigurationAccessor.GetConfig<T>(). + /// + Task ReadAsync(CancellationToken ct = default); + + /// + /// Returns per-key provenance for a management UI: the union of leaf paths from the base (lower layers, + /// without this overlay) and the overlay, each annotated with its base value, effective value, and whether + /// it is currently overridden. + /// + Task> DescribeAsync(CancellationToken ct = default); + + /// + /// The raw, key-path overlay surface for dynamic or non-expressible paths. + /// + ILocalStorageOverlay Overlay { get; } +} + +/// +/// Per-leaf provenance entry produced by . +/// +/// Dotted leaf path (e.g. "Smtp.Port"). +/// The value from the lower layers, without this overlay; if absent there. +/// The merged/effective value seen by the application; if absent. +/// when the overlay supplies this key. +public sealed record OverrideEntry( + string KeyPath, + JsonElement? BaseValue, + JsonElement? EffectiveValue, + bool IsOverridden); diff --git a/src/Cocoar.Configuration.Abstractions/LocalStorage/ILocalStorageOverlay.cs b/src/Cocoar.Configuration.Abstractions/LocalStorage/ILocalStorageOverlay.cs new file mode 100644 index 0000000..681c390 --- /dev/null +++ b/src/Cocoar.Configuration.Abstractions/LocalStorage/ILocalStorageOverlay.cs @@ -0,0 +1,63 @@ +using System.Text.Json.Nodes; + +namespace Cocoar.Configuration.LocalStorage; + +/// +/// Raw, key-path patch surface for a LocalStorage override layer. +/// +/// LocalStorage contributes a sparse overlay: only the leaf keys it explicitly contains +/// override the lower configuration layers (files, environment, …). Keys that are absent from the +/// overlay inherit their value from those lower layers. This interface is the dependency-free +/// (JsonNode-based) escape hatch used by the typed facade and by +/// callers that need to address dynamic / non-expressible paths. +/// +/// +/// Segments of the key path must match the JSON property names used by the lower layers. +/// The typed facade resolves these automatically; when using this raw surface directly, the caller is +/// responsible for the names. Do not use this surface for secret-typed paths — see . +/// +/// +/// The configuration type this overlay targets. +public interface ILocalStorageOverlay where T : class +{ + /// + /// Sets a sparse, dotted key path (e.g. "Smtp.Port") to a JSON value, persisting only that leaf. + /// + /// Dotted path whose segments match the persisted JSON property names. + /// + /// The JSON value to store at the leaf. A reference writes an explicit JSON + /// null override (clobbers the lower-layer value to null) — this is distinct from + /// , which removes the override entirely. + /// + /// A token to cancel the write. + Task SetAsync(string keyPath, JsonNode? value, CancellationToken ct = default); + + /// + /// Sets a pre-encrypted secret envelope at a dotted key path. The value MUST be a well-formed + /// cocoar.secret envelope (produced client-side with the server's public certificate, or by the + /// Secrets CLI); plaintext and the masked "***" form are rejected, so the secret never reaches the + /// server in the clear. + /// + /// Dotted path to the secret member. + /// A well-formed encrypted secret envelope (object with type="cocoar.secret"). + /// A token to cancel the write. + /// The value is not a well-formed encrypted secret envelope. + Task SetSecretEnvelopeAsync(string keyPath, JsonNode envelope, CancellationToken ct = default); + + /// + /// Removes a key path from the overlay so the value falls back to the lower-layer (inherited) value. + /// + /// if a key was removed; if it was already absent. + Task ResetAsync(string keyPath, CancellationToken ct = default); + + /// + /// Clears the entire overlay, persisting an empty object so every key inherits again. + /// + Task ClearAsync(CancellationToken ct = default); + + /// + /// Reads the raw sparse overlay exactly as persisted (the override fragment, NOT the merged result). + /// Returns when the overlay is empty. + /// + Task ReadOverlayAsync(CancellationToken ct = default); +} diff --git a/src/Cocoar.Configuration.Abstractions/SecretEncryptionKey.cs b/src/Cocoar.Configuration.Abstractions/SecretEncryptionKey.cs new file mode 100644 index 0000000..6238871 --- /dev/null +++ b/src/Cocoar.Configuration.Abstractions/SecretEncryptionKey.cs @@ -0,0 +1,91 @@ +using System.Text.Json.Serialization; + +namespace Cocoar.Configuration.Secrets.SecretTypes; + +/// +/// Canonical algorithm identifiers for the cocoar.secret hybrid encryption scheme. +/// A single source of truth shared by and the published keys. +/// +public static class SecretAlgorithms +{ + /// Combined hybrid algorithm descriptor. "RSA-OAEP-AES256-GCM". + public const string Hybrid = "RSA-OAEP-AES256-GCM"; + + /// RSA key-wrapping algorithm (OAEP with SHA-256). "RSA-OAEP-256". + public const string KeyWrap = "RSA-OAEP-256"; + + /// Symmetric data-encryption algorithm. "AES-256-GCM". + public const string DataEncryption = "AES-256-GCM"; +} + +/// +/// The current public encryption key for one kid — the X.509 SubjectPublicKeyInfo a producer +/// (e.g. the @cocoar/secrets browser library) imports to build a matching cocoar.secret +/// envelope. Carries only public material; never a private key. +/// +public sealed record SecretEncryptionPublicKey +{ + /// Key identifier the producer stamps into the envelope kid field. + [JsonPropertyName("kid")] + public required string Kid { get; init; } + + /// Overall algorithm. "RSA-OAEP-AES256-GCM". + [JsonPropertyName("alg")] + public string Alg { get; init; } = SecretAlgorithms.Hybrid; + + /// Key-wrapping algorithm. "RSA-OAEP-256". + [JsonPropertyName("walg")] + public string Walg { get; init; } = SecretAlgorithms.KeyWrap; + + /// Data-encryption algorithm. "AES-256-GCM". + [JsonPropertyName("enc")] + public string Enc { get; init; } = SecretAlgorithms.DataEncryption; + + /// Public-key structure. Always "spki" (X.509 SubjectPublicKeyInfo, DER). + [JsonPropertyName("format")] + public string Format { get; init; } = "spki"; + + /// Encoding of . Always "base64url" (no padding). + [JsonPropertyName("encoding")] + public string Encoding { get; init; } = "base64url"; + + /// The RSA public key as DER SubjectPublicKeyInfo, base64url-encoded WITHOUT padding. + [JsonPropertyName("publicKey")] + public required string PublicKey { get; init; } +} + +/// +/// The published set of current encryption public keys (one per kid) — the wire shape of the list +/// endpoint: { "keys": [ ... ] }. The keys field name is pinned via +/// so a host JSON naming policy cannot rename it. +/// +public sealed record SecretEncryptionKeySet +{ + /// The current encryption public key for each configured kid. Never null; may be empty. + [JsonPropertyName("keys")] + public IReadOnlyList Keys { get; init; } = Array.Empty(); +} + +/// +/// Publishes the public half of the configured secrets encryption key(s) so external producers can +/// build cocoar.secret envelopes the server can later decrypt. Resolved from dependency injection. +/// +/// There is exactly ONE current key per kid — the certificate the decryption engine prefers. +/// Implementations re-read key material on every call so certificate rotation is reflected. Public keys +/// are safe to expose; no private key or plaintext is ever reachable through this API. +/// +/// +public interface ISecretEncryptionKeyProvider +{ + /// + /// Returns the current encryption public key for each configured kid (one per kid). Returns an + /// empty list when nothing is publishable; never throws for the "no keys" case. + /// + IReadOnlyList GetCurrentKeys(); + + /// + /// The current encryption public key for , or if that + /// kid is not currently published. + /// + SecretEncryptionPublicKey? GetCurrentKey(string kid); +} diff --git a/src/Cocoar.Configuration.Abstractions/SecretEnvelope.cs b/src/Cocoar.Configuration.Abstractions/SecretEnvelope.cs new file mode 100644 index 0000000..810d909 --- /dev/null +++ b/src/Cocoar.Configuration.Abstractions/SecretEnvelope.cs @@ -0,0 +1,58 @@ +using System.Text.Json.Serialization; + +namespace Cocoar.Configuration.Secrets.SecretTypes; + +/// +/// The wire / transport form of an encrypted secret — a cocoar.secret envelope produced +/// client-side (e.g. by the @cocoar/secrets browser library) or by the Secrets CLI. +/// +/// This is NOT a usable secret: it is ciphertext to be stored, and it cannot be opened without the +/// private key. It is deliberately a plain, JSON-bindable record (so an API request DTO can carry it) +/// — it does not inherit from Secret<T>, which has a different role (an openable +/// runtime secret). The phantom couples the envelope to the value type of the +/// target Secret<T> so the compiler can match them at the write call site. +/// +/// +/// Binary fields (, , , ) are +/// base64url WITHOUT padding — the encoding the decryption path requires. +/// +/// +/// The value type of the secret this envelope encrypts (phantom; for typing only). +public sealed record SecretEnvelope +{ + /// Envelope discriminator. Always "cocoar.secret". + [JsonPropertyName("type")] + public string Type { get; init; } = "cocoar.secret"; + + /// Envelope format version. Always 1. + [JsonPropertyName("version")] + public int Version { get; init; } = 1; + + /// Key identifier — must match a decryption key configured on the server. + [JsonPropertyName("kid")] + public required string Kid { get; init; } + + /// Overall algorithm. "RSA-OAEP-AES256-GCM". + [JsonPropertyName("alg")] + public string Alg { get; init; } = SecretAlgorithms.Hybrid; + + /// The AES-256 key wrapped with RSA-OAEP-SHA256 (base64url, no padding). + [JsonPropertyName("wk")] + public required string Wk { get; init; } + + /// Key-wrapping algorithm. "RSA-OAEP-256". + [JsonPropertyName("walg")] + public string Walg { get; init; } = SecretAlgorithms.KeyWrap; + + /// AES-GCM 96-bit initialization vector (base64url, no padding). + [JsonPropertyName("iv")] + public required string Iv { get; init; } + + /// AES-GCM ciphertext (base64url, no padding). + [JsonPropertyName("ct")] + public required string Ct { get; init; } + + /// AES-GCM 128-bit authentication tag (base64url, no padding). + [JsonPropertyName("tag")] + public required string Tag { get; init; } +} diff --git a/src/Cocoar.Configuration.AspNetCore/SecretEncryptionKeyEndpointExtensions.cs b/src/Cocoar.Configuration.AspNetCore/SecretEncryptionKeyEndpointExtensions.cs new file mode 100644 index 0000000..62ba956 --- /dev/null +++ b/src/Cocoar.Configuration.AspNetCore/SecretEncryptionKeyEndpointExtensions.cs @@ -0,0 +1,89 @@ +using Cocoar.Configuration.Secrets.SecretTypes; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; + +namespace Cocoar.Configuration.AspNetCore; + +/// +/// Extension methods for publishing the configured secrets encryption public key(s) so external +/// producers (e.g. a browser library) can build cocoar.secret envelopes the server decrypts. +/// Only public-key material is exposed — never a private key or plaintext. +/// +public static class SecretEncryptionKeyEndpointExtensions +{ + /// + /// Maps a GET endpoint that lists the current encryption public key per configured kid as + /// { "keys": [ ... ] }. Always returns 200 (an empty list when no key is publishable, e.g. + /// no secrets configured). Returns an so callers can chain + /// .RequireAuthorization(). Not secured by default (matches MapFeatureFlagEndpoints). + /// + public static IEndpointConventionBuilder MapSecretEncryptionKeys( + this IEndpointRouteBuilder endpoints, + string pattern = "/.well-known/cocoar/encryption-keys") + { + return endpoints.MapGet(pattern, (IServiceProvider sp) => + { + var provider = sp.GetService(); + var keys = provider?.GetCurrentKeys() ?? Array.Empty(); + return Results.Json(new SecretEncryptionKeySet { Keys = keys }); + }); + } + + /// + /// Maps a GET endpoint that returns the current encryption public key for a specific kid, or a + /// 404 ProblemDetails when that kid is not currently published. Returns an + /// for chaining .RequireAuthorization(). + /// + public static IEndpointConventionBuilder MapSecretEncryptionKeyByKid( + this IEndpointRouteBuilder endpoints, + string pattern = "/.well-known/cocoar/encryption-keys/{kid}") + { + return endpoints.MapGet(pattern, (string kid, IServiceProvider sp) => + { + var provider = sp.GetService(); + var key = provider?.GetCurrentKey(kid); + return key is null + ? Results.Problem( + detail: $"No published encryption key for kid '{kid}'.", + title: "Encryption key not found", + statusCode: StatusCodes.Status404NotFound) + : Results.Json(key); + }); + } + + /// + /// Maps both the list endpoint (at ) and the by-kid endpoint + /// (at {basePattern}/{kid}), returning a composite + /// so a single .RequireAuthorization() (or other convention) applies to both routes. + /// + public static IEndpointConventionBuilder MapSecretEncryptionKeyEndpoints( + this IEndpointRouteBuilder endpoints, + string basePattern = "/.well-known/cocoar/encryption-keys") + { + var list = endpoints.MapSecretEncryptionKeys(basePattern); + var byKid = endpoints.MapSecretEncryptionKeyByKid($"{basePattern.TrimEnd('/')}/{{kid}}"); + return new CompositeEndpointConventionBuilder(list, byKid); + } + + private sealed class CompositeEndpointConventionBuilder : IEndpointConventionBuilder + { + private readonly IEndpointConventionBuilder[] _builders; + + public CompositeEndpointConventionBuilder(params IEndpointConventionBuilder[] builders) + => _builders = builders; + + public void Add(Action convention) + { + foreach (var builder in _builders) + builder.Add(convention); + } + + public void Finally(Action finallyConvention) + { + foreach (var builder in _builders) + builder.Finally(finallyConvention); + } + } +} diff --git a/src/Cocoar.Configuration.DI/ServiceDescriptorEmitter.cs b/src/Cocoar.Configuration.DI/ServiceDescriptorEmitter.cs index 79cc371..d3559fc 100644 --- a/src/Cocoar.Configuration.DI/ServiceDescriptorEmitter.cs +++ b/src/Cocoar.Configuration.DI/ServiceDescriptorEmitter.cs @@ -1,7 +1,10 @@ using Cocoar.Configuration.Core; using Cocoar.Configuration.Flags; using Cocoar.Configuration.Flags.Internal; +using Cocoar.Configuration.Providers.Abstractions; using Cocoar.Configuration.Reactive; +using Cocoar.Configuration.Secrets.Core; +using Cocoar.Configuration.Secrets.SecretTypes; using Microsoft.Extensions.DependencyInjection; namespace Cocoar.Configuration.DI; @@ -31,6 +34,74 @@ public static void Emit( EmitFlagsServices(services, configManager); EmitEntitlementsServices(services, configManager); + EmitProviderContributedServices(services, configManager); + EmitSecretsKeyProviderServices(services, configManager); + } + + /// + /// Discovers provider options that implement and applies the + /// extra service registrations they contribute (e.g. LocalStorage's ILocalStorage<T> / + /// ILocalStorageOverlay<T>). Registrations may be eager singleton instances or resolve-time + /// factories. Collisions on the same service type are last-rule-wins; emission is ordered by service type + /// full name for determinism. + /// + private static void EmitProviderContributedServices(IServiceCollection services, ConfigManager configManager) + { + var registrations = new Dictionary(); + + foreach (var rule in configManager.Rules) + { + IProviderConfiguration options; + try + { + options = rule.ResolveProviderOptions(configManager); + } + catch + { + // A rule whose options can't be resolved at setup time contributes no services here; + // the recompute pipeline surfaces any real failure. + continue; + } + + if (options is not IProviderServiceRegistration provider) + { + continue; + } + + foreach (var registration in provider.GetServiceRegistrations(rule.ConcreteType)) + { + registrations[registration.ServiceType] = registration; // last-rule-wins + } + } + + foreach (var (serviceType, registration) in registrations.OrderBy(kvp => kvp.Key.FullName)) + { + if (registration.Instance is not null) + { + services.AddSingleton(serviceType, registration.Instance); + } + else + { + services.AddSingleton(serviceType, registration.Factory!); + } + } + } + + /// + /// Registers the public when a publishable encryption + /// key is configured (single-kid secrets compose an ). + /// The provider resolves the capability lazily per call so certificate rotation is reflected. + /// Not registered when no publishable key exists (no secrets, or decrypt-only folder mode). + /// + private static void EmitSecretsKeyProviderServices(IServiceCollection services, ConfigManager configManager) + { + var keyInfoProviders = configManager.CapabilityScope.Owner.GetComposition() + ?.GetAll(); + if (keyInfoProviders is null || keyInfoProviders.Count == 0) + return; + + services.AddSingleton( + sp => new SecretEncryptionKeyProvider(sp.GetRequiredService().CapabilityScope)); } private static void EmitFlagsServices(IServiceCollection services, ConfigManager configManager) diff --git a/src/Cocoar.Configuration.slnx b/src/Cocoar.Configuration.slnx index d66b9f8..389b20c 100644 --- a/src/Cocoar.Configuration.slnx +++ b/src/Cocoar.Configuration.slnx @@ -22,6 +22,7 @@ + diff --git a/src/Cocoar.Configuration/Core/ConfigManager.cs b/src/Cocoar.Configuration/Core/ConfigManager.cs index 8b56fa0..dce6f53 100644 --- a/src/Cocoar.Configuration/Core/ConfigManager.cs +++ b/src/Cocoar.Configuration/Core/ConfigManager.cs @@ -12,6 +12,7 @@ using Cocoar.Configuration.Rules; using Cocoar.Configuration.Reactive; using Cocoar.Configuration.Utilities; +using Cocoar.Json.Mutable; namespace Cocoar.Configuration.Core; @@ -253,6 +254,47 @@ public bool TryGetConfig(out T? value) where T : class /// The configuration type to retrieve. public JsonElement? GetConfigAsJson(Type type) => _accessor.GetConfigAsJson(type); + /// + /// Computes the merged "base" JSON for from all rule layers BELOW the + /// overlay layer identified by — i.e. the value the type would have + /// without that overlay. Used by LocalStorage to align override key casing against lower layers and to + /// report base-vs-effective provenance. + /// + /// Thread-safety: this reads each manager's LastJsonContribution without taking the recompute + /// semaphore — deliberately, because reactive notifications are published inside that semaphore, + /// so gating here would deadlock a subscriber that writes back. It is safe because a contribution is + /// written once per recompute and then replaced wholesale (never mutated in place), and the reference + /// read is atomic: a concurrent recompute can at worst make this observe a one-generation-stale but + /// internally-consistent contribution, which self-heals on the next read. + /// + /// + internal MutableJsonObject BuildBaseJson(Type configType, Func isExcludedLayer) + { + ArgumentNullException.ThrowIfNull(configType); + ArgumentNullException.ThrowIfNull(isExcludedLayer); + + var merged = new MutableJsonObject(); + foreach (var manager in _ruleManagers) + { + if (isExcludedLayer(manager)) + { + break; // the base is everything strictly below the overlay layer + } + + if (manager.TypeDefinition != configType) + { + continue; + } + + if (manager.LastJsonContribution is { } contribution) + { + MutableJsonMerge.Merge(merged, contribution); + } + } + + return merged; + } + /// /// Gets a reactive wrapper for the specified configuration type. /// The returned emits the current value immediately on subscribe diff --git a/src/Cocoar.Configuration/Properties/AssemblyInfo.cs b/src/Cocoar.Configuration/Properties/AssemblyInfo.cs index 25e7aac..0de3212 100644 --- a/src/Cocoar.Configuration/Properties/AssemblyInfo.cs +++ b/src/Cocoar.Configuration/Properties/AssemblyInfo.cs @@ -9,6 +9,7 @@ [assembly: InternalsVisibleTo("Cocoar.Configuration.Secrets.Tests")] [assembly: InternalsVisibleTo("Cocoar.Configuration.AspNetCore")] [assembly: InternalsVisibleTo("Cocoar.Configuration.Http")] +[assembly: InternalsVisibleTo("Cocoar.Configuration.Providers.Tests")] // Type forwarding for types moved to Cocoar.Configuration.Abstractions [assembly: TypeForwardedTo(typeof(IConfigurationAccessor))] diff --git a/src/Cocoar.Configuration/Providers/Abstractions/IProviderServiceRegistration.cs b/src/Cocoar.Configuration/Providers/Abstractions/IProviderServiceRegistration.cs new file mode 100644 index 0000000..6d573bf --- /dev/null +++ b/src/Cocoar.Configuration/Providers/Abstractions/IProviderServiceRegistration.cs @@ -0,0 +1,52 @@ +namespace Cocoar.Configuration.Providers.Abstractions; + +/// +/// Implemented by provider options that need additional services registered in DI +/// beyond the standard config type and IReactiveConfig<T>. +/// +/// The DI emitter discovers this interface by scanning resolved provider options +/// for all rules. No hardcoded provider knowledge is needed in the emitter. +/// +/// +public interface IProviderServiceRegistration +{ + /// + /// Returns additional service registrations (eager instances and/or resolve-time factories) to apply in DI. + /// Called once during DI setup — not on every recompute. + /// + /// The configuration type this rule targets (e.g., typeof(AppSettings)). + IEnumerable GetServiceRegistrations(Type concreteType); +} + +/// +/// A single service registration contributed by a provider. Expresses either a pre-built singleton +/// or a resolve-time . Uses only BCL types +/// () so the shipped package stays free of a DI dependency; the DI emitter +/// translates it into a concrete ServiceDescriptor. +/// +public sealed class ProviderServiceRegistration +{ + private ProviderServiceRegistration(Type serviceType, object? instance, Func? factory) + { + ServiceType = serviceType ?? throw new ArgumentNullException(nameof(serviceType)); + Instance = instance; + Factory = factory; + } + + /// The service type to register (e.g. ILocalStorage<AppSettings>). + public Type ServiceType { get; } + + /// The pre-built singleton instance, or when a is used. + public object? Instance { get; } + + /// The resolve-time factory, or when an is used. + public Func? Factory { get; } + + /// Registers a pre-built singleton instance. + public static ProviderServiceRegistration Singleton(Type serviceType, object instance) + => new(serviceType, instance ?? throw new ArgumentNullException(nameof(instance)), null); + + /// Registers a singleton built by a resolve-time factory (so it can pull other DI services). + public static ProviderServiceRegistration Singleton(Type serviceType, Func factory) + => new(serviceType, null, factory ?? throw new ArgumentNullException(nameof(factory))); +} diff --git a/src/Cocoar.Configuration/Providers/LocalStorageProvider/FileStorageBackend.cs b/src/Cocoar.Configuration/Providers/LocalStorageProvider/FileStorageBackend.cs new file mode 100644 index 0000000..9affa3f --- /dev/null +++ b/src/Cocoar.Configuration/Providers/LocalStorageProvider/FileStorageBackend.cs @@ -0,0 +1,76 @@ +namespace Cocoar.Configuration.Providers; + +/// +/// File-based storage backend using atomic write-temp-then-rename pattern. +/// Default directory: {AppContext.BaseDirectory}/.cocoar/localStorage/ +/// +public sealed class FileStorageBackend : IStorageBackend +{ + private readonly string _directory; + + public FileStorageBackend(string? directory = null) + { + _directory = directory + ?? Path.Combine(AppContext.BaseDirectory, ".cocoar", "localStorage"); + } + + public async Task ReadAsync(string key, CancellationToken ct = default) + { + var path = GetFilePath(key); + + try + { + return await File.ReadAllBytesAsync(path, ct).ConfigureAwait(false); + } + catch (FileNotFoundException) + { + // Nothing persisted yet, or the file was removed between checks (TOCTOU-safe). + return null; + } + catch (DirectoryNotFoundException) + { + return null; + } + } + + public async Task WriteAsync(string key, byte[] data, CancellationToken ct = default) + { + Directory.CreateDirectory(_directory); + + var path = GetFilePath(key); + // Per-write unique temp name so concurrent writers never clobber each other's intermediate file. + var tempPath = path + "." + Guid.NewGuid().ToString("N") + ".tmp"; + + try + { + await File.WriteAllBytesAsync(tempPath, data, ct).ConfigureAwait(false); + File.Move(tempPath, path, overwrite: true); + } + catch + { + TryDeleteTemp(tempPath); + throw; + } + } + + private static void TryDeleteTemp(string tempPath) + { + try + { + if (File.Exists(tempPath)) + { + File.Delete(tempPath); + } + } + catch + { + // Best-effort cleanup of a stale temp file; never mask the original write failure. + } + } + + private string GetFilePath(string key) + { + var safeKey = string.Join("_", key.Split(Path.GetInvalidFileNameChars())); + return Path.Combine(_directory, safeKey + ".json"); + } +} diff --git a/src/Cocoar.Configuration/Providers/LocalStorageProvider/IStorageBackend.cs b/src/Cocoar.Configuration/Providers/LocalStorageProvider/IStorageBackend.cs new file mode 100644 index 0000000..278f5e8 --- /dev/null +++ b/src/Cocoar.Configuration/Providers/LocalStorageProvider/IStorageBackend.cs @@ -0,0 +1,19 @@ +namespace Cocoar.Configuration.Providers; + +/// +/// Abstraction for the persistence layer used by LocalStorageProvider. +/// Default implementation is file-based; can be replaced with SQLite, Marten, etc. +/// +public interface IStorageBackend +{ + /// + /// Reads raw UTF-8 JSON bytes for the given key. + /// Returns null if no data has been persisted yet. + /// + Task ReadAsync(string key, CancellationToken ct = default); + + /// + /// Writes raw UTF-8 JSON bytes atomically for the given key. + /// + Task WriteAsync(string key, byte[] data, CancellationToken ct = default); +} diff --git a/src/Cocoar.Configuration/Providers/LocalStorageProvider/LocalStorageAdapter.cs b/src/Cocoar.Configuration/Providers/LocalStorageProvider/LocalStorageAdapter.cs new file mode 100644 index 0000000..f794dab --- /dev/null +++ b/src/Cocoar.Configuration/Providers/LocalStorageProvider/LocalStorageAdapter.cs @@ -0,0 +1,253 @@ +using System.Linq.Expressions; +using System.Text.Json; +using System.Text.Json.Nodes; +using Cocoar.Configuration.Core; +using Cocoar.Configuration.LocalStorage; +using Cocoar.Configuration.Rules; +using Cocoar.Configuration.Secrets.Core; +using Cocoar.Configuration.Secrets.SecretTypes; +using Cocoar.Json.Mutable; + +namespace Cocoar.Configuration.Providers; + +/// +/// Implements both the type-safe facade and the raw +/// surface over a single . +/// All writes are sparse (only the touched leaf is persisted) and go through the store's atomic +/// read-transform-write lock; provenance is computed from the base layers, the merged effective value, +/// and the persisted overlay. +/// +internal sealed class LocalStorageAdapter : ILocalStorage, ILocalStorageOverlay, IDisposable + where T : class +{ + private readonly ConfigManager _configManager; + private readonly LocalStorageStore _store; + + public LocalStorageAdapter(ConfigManager configManager, LocalStorageStore store) + { + _configManager = configManager ?? throw new ArgumentNullException(nameof(configManager)); + _store = store ?? throw new ArgumentNullException(nameof(store)); + } + + public ILocalStorageOverlay Overlay => this; + + // ---------------------------------------------------------------- typed facade + + public Task SetAsync(Expression> selector, TValue value, CancellationToken ct = default) + { + if (OverlayPathResolver.ContainsSecret(typeof(TValue))) + { + throw new NotSupportedException( + $"Cannot store a value of type '{typeof(TValue).Name}' via SetAsync because it is, or contains, a secret. " + + "A secret would be serialized as plaintext (or lost). Set secret members individually via " + + "SetSecretAsync with a pre-encrypted SecretEnvelope."); + } + + var keyPath = OverlayPathResolver.ResolveKeyPath(selector); + var node = OverlaySerialization.SerializeValue(value); + return SetAsync(keyPath, node, ct); + } + + public Task ResetAsync(Expression> selector, CancellationToken ct = default) + { + var keyPath = OverlayPathResolver.ResolveKeyPath(selector); + return ResetAsync(keyPath, ct); + } + + public Task SetSecretAsync(Expression>> selector, SecretEnvelope envelope, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(envelope); + // Secret members are allowed here ONLY because the value is a pre-encrypted envelope (validated below). + var keyPath = OverlayPathResolver.ResolveKeyPath(selector, allowSecretMembers: true); + var node = JsonSerializer.SerializeToNode(envelope)!; + return SetSecretEnvelopeAsync(keyPath, node, ct); + } + + public async Task ReadAsync(CancellationToken ct = default) + { + var bytes = await _store.ReadBytesAsync(ct).ConfigureAwait(false); + if (bytes.Length <= 2) + { + return null; + } + + return JsonSerializer.Deserialize(bytes, OverlaySerialization.ReadOptions); + } + + public async Task> DescribeAsync(CancellationToken ct = default) + { + var baseElement = ToJsonElement(_configManager.BuildBaseJson(typeof(T), IsThisLayer)); + var effective = _configManager.GetConfigAsJson(typeof(T)); + var overlayNode = await ReadOverlayAsync(ct).ConfigureAwait(false); + + var overriddenPaths = new HashSet(StringComparer.Ordinal); + if (overlayNode is JsonObject overlayObject) + { + CollectOverlayPaths(overlayObject, null, overriddenPaths); + } + + var allPaths = new SortedSet(StringComparer.Ordinal); + CollectLeafPaths(baseElement, null, allPaths); + if (effective is { } effectiveElement) + { + CollectLeafPaths(effectiveElement, null, allPaths); + } + allPaths.UnionWith(overriddenPaths); + + var entries = new List(allPaths.Count); + foreach (var path in allPaths) + { + JsonElement? baseValue = TrySelect(baseElement, path, out var bv) ? bv : null; + JsonElement? effectiveValue = + effective is { } e && TrySelect(e, path, out var ev) ? ev : null; + + entries.Add(new OverrideEntry(path, baseValue, effectiveValue, overriddenPaths.Contains(path))); + } + + return entries; + } + + // ---------------------------------------------------------------- raw overlay surface + + public async Task SetAsync(string keyPath, JsonNode? value, CancellationToken ct = default) + { + ValidateKeyPath(keyPath); + var baseDom = _configManager.BuildBaseJson(typeof(T), IsThisLayer); + await _store.UpdateBytesAsync(bytes => SparseOverlayMutator.Set(bytes, keyPath, value, baseDom), ct) + .ConfigureAwait(false); + } + + public async Task SetSecretEnvelopeAsync(string keyPath, JsonNode envelope, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(envelope); + ValidateKeyPath(keyPath); + + // Only pre-encrypted envelopes are accepted — never plaintext (which would expose the secret) nor the + // masked "***" form (which would destroy a real secret on the lower layer). + var element = JsonSerializer.SerializeToElement(envelope); + if (!SecretEnvelopeWrapper.IsEnvelope(element)) + { + throw new ArgumentException( + "Value is not a well-formed encrypted secret envelope (expected an object with " + + "type=\"cocoar.secret\" and version=1). LocalStorage only accepts pre-encrypted secret envelopes.", + nameof(envelope)); + } + + var baseDom = _configManager.BuildBaseJson(typeof(T), IsThisLayer); + await _store.UpdateBytesAsync(bytes => SparseOverlayMutator.Set(bytes, keyPath, envelope, baseDom), ct) + .ConfigureAwait(false); + } + + public async Task ResetAsync(string keyPath, CancellationToken ct = default) + { + ValidateKeyPath(keyPath); + var removed = false; + await _store.UpdateBytesAsync(bytes => + { + var (updated, didRemove) = SparseOverlayMutator.Remove(bytes, keyPath); + removed = didRemove; + return updated; + }, ct).ConfigureAwait(false); + return removed; + } + + public Task ClearAsync(CancellationToken ct = default) + => _store.WriteBytesAsync("{}"u8.ToArray(), ct); + + public async Task ReadOverlayAsync(CancellationToken ct = default) + { + var bytes = await _store.ReadBytesAsync(ct).ConfigureAwait(false); + if (bytes.Length <= 2) + { + return null; + } + + return JsonNode.Parse(bytes); + } + + public void Dispose() => _store.Dispose(); + + // ---------------------------------------------------------------- helpers + + private bool IsThisLayer(IRuleManager manager) + => manager.CurrentProvider is LocalStorageProvider provider && ReferenceEquals(provider.Store, _store); + + private static void ValidateKeyPath(string keyPath) + { + if (string.IsNullOrWhiteSpace(keyPath)) + { + throw new ArgumentException("Key path must be a non-empty, dotted property path.", nameof(keyPath)); + } + + foreach (var segment in keyPath.Split('.')) + { + if (string.IsNullOrWhiteSpace(segment)) + { + throw new ArgumentException( + $"Key path '{keyPath}' contains an empty segment.", nameof(keyPath)); + } + } + } + + private static JsonElement ToJsonElement(MutableJsonObject obj) + { + var bytes = MutableJsonDocument.ToUtf8Bytes(obj); + using var document = JsonDocument.Parse(bytes); + return document.RootElement.Clone(); + } + + private static void CollectLeafPaths(JsonElement element, string? prefix, ISet paths) + { + if (element.ValueKind == JsonValueKind.Object) + { + foreach (var property in element.EnumerateObject()) + { + var childPath = prefix is null ? property.Name : $"{prefix}.{property.Name}"; + CollectLeafPaths(property.Value, childPath, paths); + } + + return; + } + + // Arrays and scalars (and explicit null) are treated as leaves — the merge replaces arrays wholesale. + if (prefix is not null) + { + paths.Add(prefix); + } + } + + private static void CollectOverlayPaths(JsonObject node, string? prefix, ISet paths) + { + foreach (var (key, value) in node) + { + var childPath = prefix is null ? key : $"{prefix}.{key}"; + if (value is JsonObject childObject) + { + CollectOverlayPaths(childObject, childPath, paths); + } + else + { + // A JsonValue, JsonArray, or explicit null (value is null) is an overridden leaf. + paths.Add(childPath); + } + } + } + + private static bool TrySelect(JsonElement root, string dottedPath, out JsonElement result) + { + var current = root; + foreach (var segment in dottedPath.Split('.')) + { + if (current.ValueKind != JsonValueKind.Object || !current.TryGetProperty(segment, out var next)) + { + result = default; + return false; + } + + current = next; + } + + result = current; + return true; + } +} diff --git a/src/Cocoar.Configuration/Providers/LocalStorageProvider/LocalStorageProvider.cs b/src/Cocoar.Configuration/Providers/LocalStorageProvider/LocalStorageProvider.cs new file mode 100644 index 0000000..ad2d75e --- /dev/null +++ b/src/Cocoar.Configuration/Providers/LocalStorageProvider/LocalStorageProvider.cs @@ -0,0 +1,29 @@ +using Cocoar.Configuration.Providers.Abstractions; + +namespace Cocoar.Configuration.Providers; + +/// +/// Provider that reads from a . +/// The store is shared state owned by the closure in FromLocalStorage() — +/// the provider does NOT own or dispose it. +/// +public sealed class LocalStorageProvider(LocalStorageProviderOptions options) + : ConfigurationProvider(options) +{ + /// + /// The store backing this provider. Exposed so the overlay layer can be located in the rule + /// pipeline by store reference (used for base/prefix computation and provenance). + /// + internal LocalStorageStore Store => ProviderOptions.Store; + + public override async Task FetchConfigurationBytesAsync( + LocalStorageProviderQueryOptions query, CancellationToken ct = default) + { + return await ProviderOptions.Store.ReadBytesAsync(ct).ConfigureAwait(false); + } + + public override IObservable ChangesAsBytes(LocalStorageProviderQueryOptions query) + { + return ProviderOptions.Store.Changes; + } +} diff --git a/src/Cocoar.Configuration/Providers/LocalStorageProvider/LocalStorageProviderOptions.cs b/src/Cocoar.Configuration/Providers/LocalStorageProvider/LocalStorageProviderOptions.cs new file mode 100644 index 0000000..22be37c --- /dev/null +++ b/src/Cocoar.Configuration/Providers/LocalStorageProvider/LocalStorageProviderOptions.cs @@ -0,0 +1,44 @@ +using Cocoar.Configuration.Core; +using Cocoar.Configuration.LocalStorage; +using Cocoar.Configuration.Providers.Abstractions; + +namespace Cocoar.Configuration.Providers; + +public sealed class LocalStorageProviderOptions(LocalStorageStore store) + : IProviderConfiguration, IProviderServiceRegistration +{ + public LocalStorageStore Store { get; } = store ?? throw new ArgumentNullException(nameof(store)); + + /// + /// Returns null to indicate non-reusable. Each LocalStorage rule gets its own provider instance + /// because each is backed by a unique tied to a specific configuration type. + /// + public string? GenerateProviderKey() => null; + + /// + /// Registers the overlay adapter as a singleton built at resolve time (so it can pull + /// for base/prefix computation), then resolves both + /// and to that one instance. + /// + public IEnumerable GetServiceRegistrations(Type concreteType) + { + var adapterType = typeof(LocalStorageAdapter<>).MakeGenericType(concreteType); + var storageInterface = typeof(ILocalStorage<>).MakeGenericType(concreteType); + var overlayInterface = typeof(ILocalStorageOverlay<>).MakeGenericType(concreteType); + var store = Store; + + yield return ProviderServiceRegistration.Singleton(adapterType, sp => + { + var configManager = (ConfigManager?)sp.GetService(typeof(ConfigManager)) + ?? throw new InvalidOperationException( + "ConfigManager is not registered. Call AddCocoarConfiguration before resolving ILocalStorage."); + return Activator.CreateInstance(adapterType, configManager, store)!; + }); + + yield return ProviderServiceRegistration.Singleton(storageInterface, + sp => sp.GetService(adapterType)!); + + yield return ProviderServiceRegistration.Singleton(overlayInterface, + sp => sp.GetService(adapterType)!); + } +} diff --git a/src/Cocoar.Configuration/Providers/LocalStorageProvider/LocalStorageProviderQueryOptions.cs b/src/Cocoar.Configuration/Providers/LocalStorageProvider/LocalStorageProviderQueryOptions.cs new file mode 100644 index 0000000..1fc5160 --- /dev/null +++ b/src/Cocoar.Configuration/Providers/LocalStorageProvider/LocalStorageProviderQueryOptions.cs @@ -0,0 +1,8 @@ +using Cocoar.Configuration.Providers.Abstractions; + +namespace Cocoar.Configuration.Providers; + +public sealed class LocalStorageProviderQueryOptions : IProviderQuery +{ + public static readonly LocalStorageProviderQueryOptions Default = new(); +} diff --git a/src/Cocoar.Configuration/Providers/LocalStorageProvider/LocalStorageRulesExtensions.cs b/src/Cocoar.Configuration/Providers/LocalStorageProvider/LocalStorageRulesExtensions.cs new file mode 100644 index 0000000..22851a3 --- /dev/null +++ b/src/Cocoar.Configuration/Providers/LocalStorageProvider/LocalStorageRulesExtensions.cs @@ -0,0 +1,87 @@ +using Cocoar.Configuration.Core; +using Cocoar.Configuration.Fluent; + +namespace Cocoar.Configuration.Providers; + +public static class LocalStorageRulesExtensions +{ + /// + /// Creates a local-storage-backed configuration rule. + /// Reads from and writes to persistent storage. By default uses file-based storage + /// at {AppContext.BaseDirectory}/.cocoar/localStorage/. + /// + /// + /// + /// Use (via DI) to write configuration at runtime. + /// Writes trigger a recompute of the configuration pipeline. + /// + /// + /// Position this rule in the pipeline to control priority: later rules override earlier ones. + /// + /// + /// The typed provider builder. + /// Optional custom storage backend. Defaults to . + public static ProviderRuleBuilder + FromLocalStorage(this TypedProviderBuilder builder, IStorageBackend? backend = null) + where T : class + { + var effectiveBackend = backend ?? new FileStorageBackend(); + var storageKey = typeof(T).FullName ?? typeof(T).Name; + var store = new LocalStorageStore(effectiveBackend, storageKey) + { + ConfigurationType = typeof(T) + }; + + return new( + _ => new LocalStorageProviderOptions(store), + _ => LocalStorageProviderQueryOptions.Default, + typeof(T) + ); + } + + /// + /// Creates a local-storage-backed configuration rule using a factory that receives the current + /// configuration state and the current backend. Use this when the storage backend depends on + /// values from earlier rules (e.g., a connection string for a database-backed backend). + /// + /// + /// The factory is called on every recompute. The second parameter (currentBackend) is + /// the backend currently in use (null on the first call). Return it unchanged to skip + /// creating a new instance when nothing relevant changed — this avoids unnecessary + /// connection pool churn for database backends. + /// + /// The typed provider builder. + /// A factory that receives the current + /// and the current (null on first call), and returns the backend to use. + public static ProviderRuleBuilder + FromLocalStorage(this TypedProviderBuilder builder, Func backendFactory) + where T : class + { + ArgumentNullException.ThrowIfNull(backendFactory); + + LocalStorageStore? store = null; + var storageKey = typeof(T).FullName ?? typeof(T).Name; + + return new( + accessor => + { + var currentBackend = store?.Backend; + var backend = backendFactory(accessor, currentBackend); + if (store is null) + { + store = new LocalStorageStore(backend, storageKey) + { + ConfigurationType = typeof(T) + }; + } + else if (!ReferenceEquals(backend, currentBackend)) + { + store.ReplaceBackend(backend); + } + return new LocalStorageProviderOptions(store); + }, + _ => LocalStorageProviderQueryOptions.Default, + typeof(T) + ); + } +} diff --git a/src/Cocoar.Configuration/Providers/LocalStorageProvider/LocalStorageStore.cs b/src/Cocoar.Configuration/Providers/LocalStorageProvider/LocalStorageStore.cs new file mode 100644 index 0000000..c5c4ea4 --- /dev/null +++ b/src/Cocoar.Configuration/Providers/LocalStorageProvider/LocalStorageStore.cs @@ -0,0 +1,108 @@ +using Cocoar.Configuration.Reactive.Internal; + +namespace Cocoar.Configuration.Providers; + +/// +/// Shared state object that bridges the provider (read path) and ILocalStorage<T> (write path). +/// Created once in FromLocalStorage() and captured by both the provider options closure and DI registration. +/// +public sealed class LocalStorageStore : IDisposable +{ + private volatile IStorageBackend _backend; + private readonly string _storageKey; + private readonly SimpleSubject _changeSubject = new(); + private readonly SemaphoreSlim _writeLock = new(1, 1); + + public LocalStorageStore(IStorageBackend backend, string storageKey) + { + ArgumentNullException.ThrowIfNull(backend); + ArgumentException.ThrowIfNullOrWhiteSpace(storageKey); + + _backend = backend; + _storageKey = storageKey; + } + + /// + /// The current storage backend. Exposed so the config-aware factory can + /// pass it back to the user for comparison/reuse decisions. + /// + internal IStorageBackend Backend => _backend; + + /// + /// Replaces the storage backend. Called during recompute when config-aware + /// factory produces a new backend (e.g., connection string changed). + /// The store instance stays the same — DI references remain valid. + /// + internal void ReplaceBackend(IStorageBackend backend) + { + ArgumentNullException.ThrowIfNull(backend); + _backend = backend; + } + + /// + /// The configuration type this store is associated with. + /// Used by DI registration to match ILocalStorage<T> to the correct store. + /// + public Type ConfigurationType { get; init; } = null!; + + /// + /// Observable that fires when new bytes are written. Subscribed to by the provider. + /// + internal IObservable Changes => _changeSubject; + + /// + /// Reads current bytes from storage. Returns empty JSON object if nothing persisted yet. + /// + internal async Task ReadBytesAsync(CancellationToken ct = default) + { + var data = await _backend.ReadAsync(_storageKey, ct).ConfigureAwait(false); + return data ?? "{}"u8.ToArray(); + } + + /// + /// Writes bytes to storage and signals the change subject. + /// Thread-safe via SemaphoreSlim. + /// + public async Task WriteBytesAsync(byte[] data, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(data); + + await _writeLock.WaitAsync(ct).ConfigureAwait(false); + try + { + await _backend.WriteAsync(_storageKey, data, ct).ConfigureAwait(false); + _changeSubject.OnNext(data); + } + finally + { + _writeLock.Release(); + } + } + + /// + /// Atomically reads current bytes, applies a transform, and writes the result. + /// The entire read-transform-write cycle runs under the write lock. + /// + internal async Task UpdateBytesAsync(Func transform, CancellationToken ct = default) + { + await _writeLock.WaitAsync(ct).ConfigureAwait(false); + try + { + var current = await _backend.ReadAsync(_storageKey, ct).ConfigureAwait(false) + ?? "{}"u8.ToArray(); + var updated = transform(current); + await _backend.WriteAsync(_storageKey, updated, ct).ConfigureAwait(false); + _changeSubject.OnNext(updated); + } + finally + { + _writeLock.Release(); + } + } + + public void Dispose() + { + _writeLock.Dispose(); + _changeSubject.Dispose(); + } +} diff --git a/src/Cocoar.Configuration/Providers/LocalStorageProvider/OverlayPathResolver.cs b/src/Cocoar.Configuration/Providers/LocalStorageProvider/OverlayPathResolver.cs new file mode 100644 index 0000000..f00dfee --- /dev/null +++ b/src/Cocoar.Configuration/Providers/LocalStorageProvider/OverlayPathResolver.cs @@ -0,0 +1,156 @@ +using System.Linq.Expressions; +using System.Reflection; +using System.Text.Json.Serialization; +using Cocoar.Configuration.Secrets.SecretTypes; + +namespace Cocoar.Configuration.Providers; + +/// +/// Translates a member-access selector (e.g. x => x.Smtp.Port) into a dotted JSON key path, +/// resolving each segment's JSON property name (honoring ) and +/// rejecting unsupported selectors (indexers, method calls, casts) and secret-typed members. +/// +internal static class OverlayPathResolver +{ + internal static string ResolveKeyPath(Expression> selector, bool allowSecretMembers = false) + { + ArgumentNullException.ThrowIfNull(selector); + + var body = Unwrap(selector.Body); + + var members = new List(); + var current = body; + while (current is MemberExpression memberExpression) + { + if (memberExpression.Member is not PropertyInfo and not FieldInfo) + { + throw Unsupported(selector); + } + + members.Add(memberExpression.Member); + current = Unwrap(memberExpression.Expression); + } + + if (current is not ParameterExpression || members.Count == 0) + { + throw Unsupported(selector); + } + + members.Reverse(); // root → leaf + + var segments = new string[members.Count]; + for (var i = 0; i < members.Count; i++) + { + var member = members[i]; + var memberType = GetMemberType(member); + + if (!allowSecretMembers && IsSecretType(memberType)) + { + throw new NotSupportedException( + $"Member '{member.Name}' is a secret and cannot be set as plaintext via LocalStorage. " + + "Use SetSecretAsync with a pre-encrypted envelope, or manage secrets via the Secrets CLI/provider."); + } + + segments[i] = ResolveJsonName(member); + } + + return string.Join('.', segments); + } + + private static Expression? Unwrap(Expression? expression) + => expression is UnaryExpression { NodeType: ExpressionType.Convert or ExpressionType.ConvertChecked } unary + ? unary.Operand + : expression; + + private static string ResolveJsonName(MemberInfo member) + => member.GetCustomAttribute()?.Name ?? member.Name; + + private static Type GetMemberType(MemberInfo member) => member switch + { + PropertyInfo property => property.PropertyType, + FieldInfo field => field.FieldType, + _ => throw new NotSupportedException($"Unsupported member kind: {member.GetType().Name}."), + }; + + private static bool IsSecretType(Type type) + { + if (!type.IsGenericType) + { + return false; + } + + var definition = type.GetGenericTypeDefinition(); + return definition == typeof(Secret<>) || definition == typeof(ISecret<>); + } + + /// + /// True if is a secret, or contains a secret anywhere in its object graph + /// (nested property/field, collection element, array element). Used to reject plaintext writes of + /// objects that carry secrets — those must be set per-leaf via SetSecretAsync with an encrypted envelope. + /// + internal static bool ContainsSecret(Type type) => ContainsSecret(type, new HashSet()); + + private static bool ContainsSecret(Type? type, HashSet visited) + { + if (type is null || !visited.Add(type)) + { + return false; + } + + if (IsSecretType(type)) + { + return true; + } + + var underlying = Nullable.GetUnderlyingType(type); + if (underlying is not null) + { + return ContainsSecret(underlying, visited); + } + + if (type.IsArray && ContainsSecret(type.GetElementType(), visited)) + { + return true; + } + + // Generic arguments cover collections/dictionaries (List>, Dictionary>, …). + foreach (var arg in type.GetGenericArguments()) + { + if (ContainsSecret(arg, visited)) + { + return true; + } + } + + // Don't walk the members of BCL/primitive types — their generic args (handled above) are enough. + if (type.IsPrimitive || type.IsEnum || type == typeof(string) || type.Namespace?.StartsWith("System", StringComparison.Ordinal) == true) + { + return false; + } + + const BindingFlags flags = BindingFlags.Public | BindingFlags.Instance; + foreach (var property in type.GetProperties(flags)) + { + if (property.GetIndexParameters().Length == 0 && ContainsSecret(property.PropertyType, visited)) + { + return true; + } + } + + foreach (var field in type.GetFields(flags)) + { + if (ContainsSecret(field.FieldType, visited)) + { + return true; + } + } + + return false; + } + + private static NotSupportedException Unsupported(Expression> selector) + => new( + $"Selector '{selector}' is not supported. Only simple member-access chains are allowed " + + "(no method calls, indexers, or array element access). " + + "Use the raw Overlay surface for dynamic key paths."); +} diff --git a/src/Cocoar.Configuration/Providers/LocalStorageProvider/OverlaySerialization.cs b/src/Cocoar.Configuration/Providers/LocalStorageProvider/OverlaySerialization.cs new file mode 100644 index 0000000..5db80b9 --- /dev/null +++ b/src/Cocoar.Configuration/Providers/LocalStorageProvider/OverlaySerialization.cs @@ -0,0 +1,44 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; + +namespace Cocoar.Configuration.Providers; + +/// +/// JSON options for reading and writing LocalStorage overlay values. +/// +/// These are deliberately vanilla: the configuration pipeline's options carry custom converters +/// (notably StringToPrimitiveConverter<T>, whose Write re-enters with the same options and +/// stack-overflows on bare primitives). For overlay writes we need only enum-as-string round-tripping so the +/// values match what the case-insensitive pipeline deserializer reads back. +/// +/// +internal static class OverlaySerialization +{ + /// Options for serializing an override value to a sparse leaf (no pipeline converters — Trap A). + internal static readonly JsonSerializerOptions WriteOptions = CreateOptions(); + + /// Options for deserializing the sparse overlay back into a partial T (case-insensitive keys). + internal static readonly JsonSerializerOptions ReadOptions = CreateReadOptions(); + + private static JsonSerializerOptions CreateOptions() + { + var options = new JsonSerializerOptions(); + options.Converters.Add(new JsonStringEnumConverter()); + return options; + } + + private static JsonSerializerOptions CreateReadOptions() + { + var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; + options.Converters.Add(new JsonStringEnumConverter()); + return options; + } + + /// + /// Serializes a typed override value to a . A reference + /// produces a node, which the overlay persists as an explicit JSON null. + /// + internal static JsonNode? SerializeValue(TValue value) + => JsonSerializer.SerializeToNode(value, WriteOptions); +} diff --git a/src/Cocoar.Configuration/Providers/LocalStorageProvider/SparseOverlayMutator.cs b/src/Cocoar.Configuration/Providers/LocalStorageProvider/SparseOverlayMutator.cs new file mode 100644 index 0000000..6a5bbc9 --- /dev/null +++ b/src/Cocoar.Configuration/Providers/LocalStorageProvider/SparseOverlayMutator.cs @@ -0,0 +1,152 @@ +using System.Text; +using System.Text.Json.Nodes; +using Cocoar.Json.Mutable; + +namespace Cocoar.Configuration.Providers; + +/// +/// Applies sparse mutations to the persisted overlay bytes: setting a single leaf (creating intermediate +/// objects on demand) and removing a leaf (pruning emptied ancestors). Key casing is aligned to the existing +/// overlay first, then to the base layers (Trap B), so an override lands byte-identically on the lower-layer +/// key and acts as an override rather than a sibling. +/// +internal static class SparseOverlayMutator +{ + /// + /// Returns new overlay bytes with set at . + /// A writes an explicit JSON-null leaf. + /// + internal static byte[] Set(byte[] currentBytes, string keyPath, JsonNode? valueNode, MutableJsonObject? baseDom) + { + var root = MutableJsonDocument.Parse(currentBytes) as MutableJsonObject ?? new MutableJsonObject(); + var segments = keyPath.Split('.'); + + var parent = root; + var baseObj = baseDom; + + for (var i = 0; i < segments.Length - 1; i++) + { + var name = ResolveName(parent, baseObj, segments[i]); + + if (TryGetChild(parent, name) is not MutableJsonObject child) + { + child = new MutableJsonObject(); + parent.Set(name, child); + } + + parent = child; + // Descend the base by the BASE's own key for this segment (case-insensitive), independent of the + // name chosen for the overlay — so leaf casing keeps aligning to the base even if an intermediate + // overlay key has drifted from the base's casing (Trap B, resolve against the base position). + baseObj = DescendBase(baseObj, segments[i]); + } + + var leafName = ResolveName(parent, baseObj, segments[^1]); + parent.Set(leafName, ToMutable(valueNode)); + + return MutableJsonDocument.ToUtf8Bytes(root); + } + + /// + /// Returns new overlay bytes with the leaf at removed and any ancestors that + /// became empty pruned. The boolean indicates whether anything was removed (idempotent no-op if absent). + /// + internal static (byte[] Bytes, bool Removed) Remove(byte[] currentBytes, string keyPath) + { + if (MutableJsonDocument.Parse(currentBytes) is not MutableJsonObject root) + { + return (currentBytes, false); + } + + var segments = keyPath.Split('.'); + var chain = new List<(MutableJsonObject Parent, string Name)>(segments.Length - 1); + var parent = root; + + for (var i = 0; i < segments.Length - 1; i++) + { + var name = FindExistingName(parent, segments[i]); + if (name is null || TryGetChild(parent, name) is not MutableJsonObject child) + { + return (currentBytes, false); // path not present → not overridden + } + + chain.Add((parent, name)); + parent = child; + } + + var leafName = FindExistingName(parent, segments[^1]); + if (leafName is null) + { + return (currentBytes, false); + } + + parent.Remove(leafName); + + // Prune empty ancestors bottom-up so ReadOverlay / provenance stays clean. + for (var i = chain.Count - 1; i >= 0; i--) + { + var (ancestor, name) = chain[i]; + if (TryGetChild(ancestor, name) is MutableJsonObject obj && obj.Properties.Count == 0) + { + ancestor.Remove(name); + } + else + { + break; + } + } + + return (MutableJsonDocument.ToUtf8Bytes(root), true); + } + + private static MutableJsonNode ToMutable(JsonNode? node) + => node is null + ? MutableJsonNull.Instance + : MutableJsonDocument.Parse(Encoding.UTF8.GetBytes(node.ToJsonString())); + + /// + /// Resolves the exact key name to write: reuse an existing overlay key (avoid casing-variant siblings), + /// else reuse the base layer's key casing (Trap B), else fall back to the supplied default. + /// + private static string ResolveName(MutableJsonObject overlayParent, MutableJsonObject? baseObj, string defaultName) + => FindExistingName(overlayParent, defaultName) + ?? (baseObj is null ? null : FindExistingName(baseObj, defaultName)) + ?? defaultName; + + private static MutableJsonObject? DescendBase(MutableJsonObject? baseObj, string segment) + { + if (baseObj is null) + { + return null; + } + + var baseName = FindExistingName(baseObj, segment); + return baseName is not null ? TryGetChild(baseObj, baseName) as MutableJsonObject : null; + } + + private static string? FindExistingName(MutableJsonObject obj, string name) + { + foreach (var property in obj.Properties) + { + if (string.Equals(property.Name, name, StringComparison.OrdinalIgnoreCase)) + { + return property.Name; + } + } + + return null; + } + + private static MutableJsonNode? TryGetChild(MutableJsonObject obj, string exactName) + { + foreach (var property in obj.Properties) + { + if (string.Equals(property.Name, exactName, StringComparison.Ordinal)) + { + return property.Value; + } + } + + return null; + } +} diff --git a/src/Cocoar.Configuration/Rules/AggregateRuleManager.cs b/src/Cocoar.Configuration/Rules/AggregateRuleManager.cs index 4a69955..f2ff66e 100644 --- a/src/Cocoar.Configuration/Rules/AggregateRuleManager.cs +++ b/src/Cocoar.Configuration/Rules/AggregateRuleManager.cs @@ -1,5 +1,6 @@ using Cocoar.Configuration.Core; using Cocoar.Configuration.Infrastructure; +using Cocoar.Configuration.Providers.Abstractions; using Cocoar.Configuration.Reactive.Internal; using Cocoar.Json.Mutable; using Microsoft.Extensions.Logging; @@ -66,6 +67,8 @@ public AggregateRuleManager(AggregateConfigRule rule, ILogger logger, ProviderRe public IReadOnlyList? SubManagers => _internalManagers; + public ConfigurationProvider? CurrentProvider => null; + public async Task?> ComputeAsync(IConfigurationAccessor accessor, CancellationToken ct) { LastFailureException = null; diff --git a/src/Cocoar.Configuration/Rules/IRuleManager.cs b/src/Cocoar.Configuration/Rules/IRuleManager.cs index 878076a..85b00a8 100644 --- a/src/Cocoar.Configuration/Rules/IRuleManager.cs +++ b/src/Cocoar.Configuration/Rules/IRuleManager.cs @@ -1,4 +1,5 @@ using Cocoar.Configuration.Core; +using Cocoar.Configuration.Providers.Abstractions; using Cocoar.Json.Mutable; namespace Cocoar.Configuration.Rules; @@ -30,6 +31,13 @@ internal interface IRuleManager : IDisposable MutableJsonObject? LastJsonContribution { get; set; } string? LastSelectionHash { get; set; } + /// + /// The provider instance currently held by this rule, or if not yet acquired + /// (or for aggregates, which have no single provider). Used by overlay providers (e.g. LocalStorage) + /// to locate their own layer in the pipeline by provider/store reference. + /// + ConfigurationProvider? CurrentProvider { get; } + Task?> ComputeAsync(IConfigurationAccessor accessor, CancellationToken ct); void ClearCachedBytes(); diff --git a/src/Cocoar.Configuration/Rules/RuleManager.cs b/src/Cocoar.Configuration/Rules/RuleManager.cs index a085123..b7ad68d 100644 --- a/src/Cocoar.Configuration/Rules/RuleManager.cs +++ b/src/Cocoar.Configuration/Rules/RuleManager.cs @@ -58,6 +58,8 @@ public string? LastSelectionHash public IReadOnlyList? SubManagers => null; + public ConfigurationProvider? CurrentProvider => _providerLease.Provider; + public RuleManager(ConfigRule rule, ILogger logger, ProviderRegistry registry) { _rule = rule; diff --git a/src/Cocoar.Configuration/Secrets/Core/ISecretEncryptionKeyInfoProvider.cs b/src/Cocoar.Configuration/Secrets/Core/ISecretEncryptionKeyInfoProvider.cs new file mode 100644 index 0000000..56fea7c --- /dev/null +++ b/src/Cocoar.Configuration/Secrets/Core/ISecretEncryptionKeyInfoProvider.cs @@ -0,0 +1,14 @@ +using Cocoar.Configuration.Secrets.SecretTypes; + +namespace Cocoar.Configuration.Secrets.Core; + +/// +/// Internal capability composed beside a secrets protector. Exposes the current encryption public key +/// for the protector's kid, re-read from the underlying certificate source on every call so rotation +/// is reflected. Composed only where a single publishable encryption kid is unambiguous. +/// +internal interface ISecretEncryptionKeyInfoProvider +{ + /// The current encryption public key, or when none is available. + SecretEncryptionPublicKey? TryGetCurrentKey(); +} diff --git a/src/Cocoar.Configuration/Secrets/Core/SecretEncryptionKeyProvider.cs b/src/Cocoar.Configuration/Secrets/Core/SecretEncryptionKeyProvider.cs new file mode 100644 index 0000000..645c08d --- /dev/null +++ b/src/Cocoar.Configuration/Secrets/Core/SecretEncryptionKeyProvider.cs @@ -0,0 +1,63 @@ +using Cocoar.Configuration.Core; +using Cocoar.Configuration.Secrets.SecretTypes; + +namespace Cocoar.Configuration.Secrets.Core; + +/// +/// Public-facing . Resolves the composed +/// capabilities lazily on every call — so certificate +/// rotation is reflected and no stale snapshot is held — and aggregates one current key per kid. +/// +internal sealed class SecretEncryptionKeyProvider : ISecretEncryptionKeyProvider +{ + private readonly ConfigManagerCapabilityScope _scope; + + public SecretEncryptionKeyProvider(ConfigManagerCapabilityScope scope) + => _scope = scope ?? throw new ArgumentNullException(nameof(scope)); + + public IReadOnlyList GetCurrentKeys() + { + var composition = _scope.Owner.GetComposition(); + var infoProviders = composition?.GetAll(); + if (infoProviders is null || infoProviders.Count == 0) + return Array.Empty(); + + // One key per kid. On a kid collision the VALUE is last-writer-wins (matching the decrypt + // resolver's recency preference); the emitted list POSITION is first-appearance. Collisions + // cannot occur in the current single-kid publishing path — revisit ordering for multi-kid. + var byKid = new Dictionary(StringComparer.Ordinal); + var order = new List(); + foreach (var info in infoProviders) + { + var key = info.TryGetCurrentKey(); + if (key is null) + continue; + + if (!byKid.ContainsKey(key.Kid)) + order.Add(key.Kid); + byKid[key.Kid] = key; + } + + if (order.Count == 0) + return Array.Empty(); + + var result = new List(order.Count); + foreach (var kid in order) + result.Add(byKid[kid]); + return result; + } + + public SecretEncryptionPublicKey? GetCurrentKey(string kid) + { + if (string.IsNullOrEmpty(kid)) + return null; + + foreach (var key in GetCurrentKeys()) + { + if (string.Equals(key.Kid, kid, StringComparison.Ordinal)) + return key; + } + + return null; + } +} diff --git a/src/Cocoar.Configuration/Secrets/Protectors/Hybrid/CertificateInventory.cs b/src/Cocoar.Configuration/Secrets/Protectors/Hybrid/CertificateInventory.cs index 86a46ab..7d6810b 100644 --- a/src/Cocoar.Configuration/Secrets/Protectors/Hybrid/CertificateInventory.cs +++ b/src/Cocoar.Configuration/Secrets/Protectors/Hybrid/CertificateInventory.cs @@ -202,6 +202,50 @@ public bool TryDecryptWithKid(HybridEnvelope envelope, string kid, out byte[] pl } } + /// + /// Exports the SubjectPublicKeyInfo (DER) of the certificate the decryption engine prefers + /// (the first in the current ordering) — for publishing as the encryption public key. Returns + /// only public-key bytes; the is never exposed. Returns + /// when no usable RSA certificate is present. Runs under the write lock + /// because mutates the cache. + /// + internal byte[]? TryExportPreferredPublicKey() + { + _lock.EnterWriteLock(); + try + { + foreach (var certPath in _sortedCertPaths) + { + X509Certificate2 cert; + try + { + cert = GetOrLoadCertificate(certPath); + } + catch (Exception ex) when ( + ex is CryptographicException or IOException or UnauthorizedAccessException + or InvalidOperationException or NotSupportedException) + { + // A cert that can't be loaded right now — e.g. a transient file race during + // rotation (delete+recreate / locked / partially written) or a non-usable file — + // is skipped so publishing degrades gracefully (empty result) instead of a 500. + continue; + } + + using var rsa = cert.GetRSAPublicKey(); + if (rsa is null) + continue; + + return rsa.ExportSubjectPublicKeyInfo(); + } + + return null; + } + finally + { + _lock.ExitWriteLock(); + } + } + private bool TryDecryptWithCert(string certPath, HybridEnvelope envelope, out byte[] plaintext) { try diff --git a/src/Cocoar.Configuration/Secrets/Protectors/Hybrid/HybridProtectorRegistrar.cs b/src/Cocoar.Configuration/Secrets/Protectors/Hybrid/HybridProtectorRegistrar.cs index 3502320..5bdf352 100644 --- a/src/Cocoar.Configuration/Secrets/Protectors/Hybrid/HybridProtectorRegistrar.cs +++ b/src/Cocoar.Configuration/Secrets/Protectors/Hybrid/HybridProtectorRegistrar.cs @@ -28,6 +28,17 @@ private void RegisterProtector(IRuntimeSecretDecryptor protector) recomposer.Build(); } + private void RegisterProtectorAndKeyInfo(IRuntimeSecretDecryptor protector, ISecretEncryptionKeyInfoProvider keyInfo) + { + var composition = _capabilityScope.Owner.GetComposition(); + if (composition == null) return; + + var recomposer = _capabilityScope.Recompose(composition); + recomposer.AddAs(protector); + recomposer.AddAs(keyInfo); + recomposer.Build(); + } + /// /// Unified method to apply certificate protector configuration. /// @@ -68,7 +79,11 @@ private void ApplySingleKidMode(CertificateProtectorConfig config, IConfiguratio // In single-kid mode with the new architecture, we create a kid folder on the fly // or we need to handle this differently. Actually, let's just register for the single kid. var protector = new SingleKidProtectorWrapper(inventory, config.ForceSingleKid!, config.AdditionalKids); - RegisterProtector(protector); + + // Publish the current encryption public key for this single, unambiguous kid. + // (Multi-kid / folder mode is decrypt-only here; per-tenant publishing comes with multi-tenancy.) + var keyInfo = new InventoryKeyInfoProvider(inventory, config.ForceSingleKid!); + RegisterProtectorAndKeyInfo(protector, keyInfo); } private void ApplyMultiKidMode(CertificateProtectorConfig config, IConfigurationAccessor configAccessor) diff --git a/src/Cocoar.Configuration/Secrets/Protectors/Hybrid/InventoryKeyInfoProvider.cs b/src/Cocoar.Configuration/Secrets/Protectors/Hybrid/InventoryKeyInfoProvider.cs new file mode 100644 index 0000000..53cf733 --- /dev/null +++ b/src/Cocoar.Configuration/Secrets/Protectors/Hybrid/InventoryKeyInfoProvider.cs @@ -0,0 +1,37 @@ +using Cocoar.Configuration.Secrets.Core; +using Cocoar.Configuration.Secrets.SecretTypes; + +namespace Cocoar.Configuration.Secrets.Protectors.Hybrid; + +/// +/// Builds the current encryption public key from a for a single +/// configured kid. Exports only public-key bytes (SPKI), base64url-encoded without padding — the +/// same codec the cocoar.secret envelope wire format uses. +/// +internal sealed class InventoryKeyInfoProvider : ISecretEncryptionKeyInfoProvider +{ + private readonly CertificateInventory _inventory; + private readonly string _kid; + + public InventoryKeyInfoProvider(CertificateInventory inventory, string kid) + { + _inventory = inventory ?? throw new ArgumentNullException(nameof(inventory)); + _kid = kid ?? throw new ArgumentNullException(nameof(kid)); + } + + public SecretEncryptionPublicKey? TryGetCurrentKey() + { + var spki = _inventory.TryExportPreferredPublicKey(); + if (spki is null) + return null; + + return new SecretEncryptionPublicKey + { + Kid = _kid, + PublicKey = ToBase64Url(spki), + }; + } + + private static string ToBase64Url(byte[] bytes) + => Convert.ToBase64String(bytes).Replace('+', '-').Replace('/', '_').TrimEnd('='); +} diff --git a/src/Examples/LocalStorageOverride/LocalStorageOverride.csproj b/src/Examples/LocalStorageOverride/LocalStorageOverride.csproj new file mode 100644 index 0000000..39f07f2 --- /dev/null +++ b/src/Examples/LocalStorageOverride/LocalStorageOverride.csproj @@ -0,0 +1,16 @@ + + + Exe + net9.0 + enable + enable + true + Examples.LocalStorageOverride + + + + + + + + diff --git a/src/Examples/LocalStorageOverride/Program.cs b/src/Examples/LocalStorageOverride/Program.cs new file mode 100644 index 0000000..30bd0ed --- /dev/null +++ b/src/Examples/LocalStorageOverride/Program.cs @@ -0,0 +1,92 @@ +using System.Diagnostics; +using Cocoar.Configuration.Core; +using Cocoar.Configuration.DI; +using Cocoar.Configuration.LocalStorage; +using Cocoar.Configuration.Providers; +using Microsoft.Extensions.DependencyInjection; + +namespace Examples.LocalStorageOverride; + +// Demonstrates LocalStorage as a SPARSE OVERRIDE OVERLAY: +// - lower layers (here, a static JSON layer) supply the DEFAULTS +// - the application overrides INDIVIDUAL values at runtime via ILocalStorage +// - only the overridden keys are persisted; everything else keeps inheriting +// - reset removes an override so the value falls back to the default again +public static class Program +{ + public sealed class SmtpSettings + { + public string? Host { get; set; } + public int Port { get; set; } + public bool UseSsl { get; set; } + } + + public static async Task Main() + { + var services = new ServiceCollection(); + services.AddCocoarConfiguration(c => c.UseConfiguration(rules => + [ + // Defaults supplied by the normal sources (a file/env/etc. — a static layer here): + rules.For().FromStaticJson("""{ "Host": "smtp.default.com", "Port": 25, "UseSsl": false }"""), + // The app-controlled override layer, placed AFTER so it wins for the keys it sets: + rules.For().FromLocalStorage(), + ])); + + using var provider = services.BuildServiceProvider(); + var manager = provider.GetRequiredService(); + var storage = provider.GetRequiredService>(); + + // Start from a clean overlay so the demo is deterministic across runs. + await storage.ClearAsync(); + await WaitUntilAsync(() => manager.GetConfig()!.Port == 25); + Print("Defaults (overlay empty)", manager.GetConfig()!); + + // Override a single value — only "Port" is persisted; Host/UseSsl keep inheriting. + await storage.SetAsync(x => x.Port, 587); + await WaitUntilAsync(() => manager.GetConfig()!.Port == 587); + Print("After SetAsync(x => x.Port, 587)", manager.GetConfig()!); + + await storage.SetAsync(x => x.UseSsl, true); + await WaitUntilAsync(() => manager.GetConfig()!.UseSsl); + Print("After SetAsync(x => x.UseSsl, true)", manager.GetConfig()!); + + // The raw stored overlay is sparse — only the two keys we set. + Console.WriteLine($"\nPersisted overlay (sparse): {await storage.Overlay.ReadOverlayAsync()}"); + + // Provenance for a management UI: base vs. effective vs. overridden, per key. + Console.WriteLine("\nDescribeAsync() — per-key provenance:"); + foreach (var entry in await storage.DescribeAsync()) + { + Console.WriteLine( + $" {entry.KeyPath,-8} base={Render(entry.BaseValue),-20} " + + $"effective={Render(entry.EffectiveValue),-20} overridden={entry.IsOverridden}"); + } + + // Reset one override — the value falls back to the default. + await storage.ResetAsync(x => x.Port); + await WaitUntilAsync(() => manager.GetConfig()!.Port == 25); + Print("\nAfter ResetAsync(x => x.Port)", manager.GetConfig()!); + + // Clear everything — back to pure defaults. + await storage.ClearAsync(); + await WaitUntilAsync(() => !manager.GetConfig()!.UseSsl); + Print("After ClearAsync()", manager.GetConfig()!); + } + + private static void Print(string label, SmtpSettings s) => + Console.WriteLine($"{label,-40} Host={s.Host}, Port={s.Port}, UseSsl={s.UseSsl}"); + + private static string Render(System.Text.Json.JsonElement? value) => + value is { } v ? v.GetRawText() : "(absent)"; + + // Writes trigger a debounced recompute; poll briefly until the effective value reflects it. + private static async Task WaitUntilAsync(Func condition) + { + var sw = Stopwatch.StartNew(); + while (sw.Elapsed < TimeSpan.FromSeconds(3)) + { + if (condition()) return; + await Task.Delay(25); + } + } +} diff --git a/src/tests/Cocoar.Configuration.AspNetCore.Tests/SecretEncryptionKeyEndpointTests.cs b/src/tests/Cocoar.Configuration.AspNetCore.Tests/SecretEncryptionKeyEndpointTests.cs new file mode 100644 index 0000000..a701259 --- /dev/null +++ b/src/tests/Cocoar.Configuration.AspNetCore.Tests/SecretEncryptionKeyEndpointTests.cs @@ -0,0 +1,214 @@ +using System.Net; +using System.Net.Http.Json; +using System.Security.Cryptography; +using System.Text.Encodings.Web; +using System.Text.Json; +using Cocoar.Configuration.AspNetCore; +using Cocoar.Configuration.DI; +using Cocoar.Configuration.Secrets; +using Cocoar.Configuration.X509Encryption; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Xunit; + +namespace Cocoar.Configuration.AspNetCore.Tests; + +public class SecretEncryptionKeyEndpointTests : IAsyncDisposable +{ + private IHost? _host; + private HttpClient? _client; + private string? _pfxPath; + + private const string BasePattern = "/.well-known/cocoar/encryption-keys"; + + /// Host with single-kid secrets configured (so a key is publishable). + private async Task CreateHostWithSecrets( + string kid, + Action? configureJson = null) + { + _pfxPath = Path.Combine(Path.GetTempPath(), "cocoar_ep_" + Guid.NewGuid().ToString("N") + ".pfx"); + using (X509CertificateGenerator.GenerateAndSavePfx(_pfxPath, password: null, "CN=Cocoar Test", overwrite: true)) { } + + var builder = WebApplication.CreateBuilder(); + builder.Services.AddCocoarConfiguration(c => c + .UseConfiguration(rules => []) + .UseSecretsSetup(secrets => secrets.UseCertificateFromFile(_pfxPath).WithKeyId(kid))); + if (configureJson is not null) + builder.Services.Configure(configureJson); + builder.WebHost.UseTestServer(); + + var app = builder.Build(); + app.MapSecretEncryptionKeyEndpoints(); + return await StartAsync(app); + } + + /// Host with NO secrets configured (the accessor service is not registered). + private async Task CreateHostWithoutSecrets() + { + var builder = WebApplication.CreateBuilder(); + builder.Services.AddCocoarConfiguration(c => c.UseConfiguration(rules => [])); + builder.WebHost.UseTestServer(); + + var app = builder.Build(); + app.MapSecretEncryptionKeyEndpoints(); + return await StartAsync(app); + } + + /// Host that gates both routes behind authorization with a deny-all scheme. + private async Task CreateHostWithAuth() + { + var builder = WebApplication.CreateBuilder(); + builder.Services.AddCocoarConfiguration(c => c.UseConfiguration(rules => [])); + builder.Services + .AddAuthentication("Deny") + .AddScheme("Deny", _ => { }); + builder.Services.AddAuthorization(); + builder.WebHost.UseTestServer(); + + var app = builder.Build(); + app.UseAuthentication(); + app.UseAuthorization(); + app.MapSecretEncryptionKeyEndpoints().RequireAuthorization(); + return await StartAsync(app); + } + + private async Task StartAsync(WebApplication app) + { + await app.StartAsync(); + _host = app; + _client = app.GetTestClient(); + return _client; + } + + public async ValueTask DisposeAsync() + { + _client?.Dispose(); + if (_host != null) await _host.StopAsync(); + _host?.Dispose(); + if (_pfxPath != null && File.Exists(_pfxPath)) File.Delete(_pfxPath); + GC.SuppressFinalize(this); + } + + [Fact] + public async Task List_WithSingleKidSecrets_Returns200WithOneKey() + { + const string kid = "endpoint-kid"; + var client = await CreateHostWithSecrets(kid); + + var response = await client.GetAsync(BasePattern); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var json = await response.Content.ReadFromJsonAsync(); + var keys = json.GetProperty("keys"); + Assert.Equal(1, keys.GetArrayLength()); + + var key = keys[0]; + Assert.Equal(kid, key.GetProperty("kid").GetString()); + Assert.Equal("spki", key.GetProperty("format").GetString()); + Assert.Equal("base64url", key.GetProperty("encoding").GetString()); + var publicKey = key.GetProperty("publicKey").GetString()!; + Assert.False(string.IsNullOrEmpty(publicKey)); + Assert.DoesNotContain('=', publicKey); + } + + [Fact] + public async Task ByKid_KnownKid_Returns200() + { + const string kid = "by-kid"; + var client = await CreateHostWithSecrets(kid); + + var response = await client.GetAsync($"{BasePattern}/{kid}"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var json = await response.Content.ReadFromJsonAsync(); + Assert.Equal(kid, json.GetProperty("kid").GetString()); + Assert.False(string.IsNullOrEmpty(json.GetProperty("publicKey").GetString())); + } + + [Fact] + public async Task ByKid_UnknownKid_Returns404() + { + var client = await CreateHostWithSecrets("configured-kid"); + + var response = await client.GetAsync($"{BasePattern}/some-other-kid"); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task List_NoSecrets_Returns200WithEmptyKeys() + { + var client = await CreateHostWithoutSecrets(); + + var response = await client.GetAsync(BasePattern); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var json = await response.Content.ReadFromJsonAsync(); + Assert.Equal(0, json.GetProperty("keys").GetArrayLength()); + } + + [Fact] + public async Task ByKid_NoSecrets_Returns404() + { + var client = await CreateHostWithoutSecrets(); + + var response = await client.GetAsync($"{BasePattern}/anything"); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task RequireAuthorization_GatesBothRoutes() + { + var client = await CreateHostWithAuth(); + + var list = await client.GetAsync(BasePattern); + var byKid = await client.GetAsync($"{BasePattern}/any"); + + // A single .RequireAuthorization() on the composite builder must apply to BOTH routes. + Assert.True( + list.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden, + $"list expected 401/403 but got {(int)list.StatusCode}"); + Assert.True( + byKid.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden, + $"by-kid expected 401/403 but got {(int)byKid.StatusCode}"); + } + + [Fact] + public async Task List_WithCustomJsonNamingPolicy_KeepsPinnedFieldNames() + { + const string kid = "policy-kid"; + var client = await CreateHostWithSecrets( + kid, + o => o.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseUpper); + + var response = await client.GetAsync(BasePattern); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var raw = await response.Content.ReadAsStringAsync(); + + // The list wrapper ("keys") and every record field are pinned via [JsonPropertyName], + // so a non-default host naming policy must NOT rename them. + Assert.Contains("\"keys\"", raw); + Assert.Contains("\"kid\"", raw); + Assert.Contains("\"publicKey\"", raw); + Assert.DoesNotContain("KEYS", raw); + Assert.DoesNotContain("PUBLIC_KEY", raw); + } + + private sealed class DenyAllHandler : AuthenticationHandler + { + public DenyAllHandler(IOptionsMonitor options, ILoggerFactory logger, UrlEncoder encoder) + : base(options, logger, encoder) + { + } + + protected override Task HandleAuthenticateAsync() + => Task.FromResult(AuthenticateResult.NoResult()); + } +} diff --git a/src/tests/Cocoar.Configuration.Providers.Tests/LocalStorage/FileStorageBackendTests.cs b/src/tests/Cocoar.Configuration.Providers.Tests/LocalStorage/FileStorageBackendTests.cs new file mode 100644 index 0000000..8b464e6 --- /dev/null +++ b/src/tests/Cocoar.Configuration.Providers.Tests/LocalStorage/FileStorageBackendTests.cs @@ -0,0 +1,95 @@ +using System.Text; +using System.Text.Json; +using Cocoar.Configuration.Providers; +using Cocoar.Configuration.Providers.Tests.TestUtilities; +using Xunit; + +namespace Cocoar.Configuration.Providers.Tests.LocalStorage; + +[Trait("Type", "Unit")] +public class FileStorageBackendTests +{ + [Fact] + public async Task ReadAsync_MissingKey_ReturnsNull() + { + using var dir = TempDirectoryHelper.Create(); + var backend = new FileStorageBackend(dir.Path); + + Assert.Null(await backend.ReadAsync("nope")); + } + + [Fact] + public async Task WriteThenRead_RoundTrips() + { + using var dir = TempDirectoryHelper.Create(); + var backend = new FileStorageBackend(dir.Path); + var payload = Encoding.UTF8.GetBytes("{\"a\":1}"); + + await backend.WriteAsync("key", payload); + var read = await backend.ReadAsync("key"); + + Assert.NotNull(read); + Assert.Equal("{\"a\":1}", Encoding.UTF8.GetString(read!)); + } + + [Fact] + public async Task Write_LeavesNoTempFiles() + { + using var dir = TempDirectoryHelper.Create(); + var backend = new FileStorageBackend(dir.Path); + + await backend.WriteAsync("key", Encoding.UTF8.GetBytes("{\"a\":1}")); + + Assert.Empty(Directory.GetFiles(dir.Path, "*.tmp")); + } + + [Fact] + public async Task ConcurrentWrites_NeverCorrupt() + { + using var dir = TempDirectoryHelper.Create(); + var backend = new FileStorageBackend(dir.Path); + + // Direct, unsynchronized concurrent writes to the same key. The per-write GUID temp guarantees no + // writer clobbers another's intermediate file, so the destination is always a complete document. + // (In production the store's write lock serializes writes; an overwrite move can still race + // transiently on Windows, which we tolerate — we assert no corruption, not zero races.) + var writes = Enumerable.Range(0, 50).Select(async i => + { + try + { + await backend.WriteAsync("key", Encoding.UTF8.GetBytes($"{{\"v\":{i}}}")); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // Transient overwrite-move race under pathological concurrency; not corruption. + } + }); + await Task.WhenAll(writes); + + var read = await backend.ReadAsync("key"); + Assert.NotNull(read); + + // Whatever ordering won, the persisted bytes must be a single, valid, uncorrupted JSON document... + using var doc = JsonDocument.Parse(read!); + Assert.True(doc.RootElement.TryGetProperty("v", out _)); + // ...and every per-write temp file must have been moved or cleaned up. + Assert.Empty(Directory.GetFiles(dir.Path, "*.tmp")); + } + + [Fact] + public async Task ConcurrentStoreWrites_AreSerializedAndConsistent() + { + using var dir = TempDirectoryHelper.Create(); + using var store = new LocalStorageStore(new FileStorageBackend(dir.Path), "key"); + + // The real production path: writes go through the store's write lock and never throw or corrupt. + var writes = Enumerable.Range(0, 50).Select(i => + store.WriteBytesAsync(Encoding.UTF8.GetBytes($"{{\"v\":{i}}}"))); + await Task.WhenAll(writes); + + var read = await store.ReadBytesAsync(); + using var doc = JsonDocument.Parse(read); + Assert.True(doc.RootElement.TryGetProperty("v", out _)); + Assert.Empty(Directory.GetFiles(dir.Path, "*.tmp")); + } +} diff --git a/src/tests/Cocoar.Configuration.Providers.Tests/LocalStorage/LocalStorageOverlayEndToEndTests.cs b/src/tests/Cocoar.Configuration.Providers.Tests/LocalStorage/LocalStorageOverlayEndToEndTests.cs new file mode 100644 index 0000000..dc2d947 --- /dev/null +++ b/src/tests/Cocoar.Configuration.Providers.Tests/LocalStorage/LocalStorageOverlayEndToEndTests.cs @@ -0,0 +1,208 @@ +using Cocoar.Configuration.Core; +using Cocoar.Configuration.DI; +using Cocoar.Configuration.LocalStorage; +using Cocoar.Configuration.Providers; +using Cocoar.Configuration.Providers.Tests.TestUtilities; +using Cocoar.Configuration.Reactive; +using Cocoar.Configuration.Rules; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Cocoar.Configuration.Providers.Tests.LocalStorage; + +[Trait("Type", "Unit")] +public sealed class LocalStorageOverlayEndToEndTests : IDisposable +{ + private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5); + + private readonly List _disposables = new(); + + private (ServiceProvider Provider, ILocalStorage Storage, ConfigManager Manager) Build( + string baseJson, InMemoryBackend? backend = null) + { + backend ??= new InMemoryBackend(); + var file = TempFileHelper.Create(baseJson); + _disposables.Add(file); + + var services = new ServiceCollection(); + services.AddCocoarConfiguration(c => c.UseConfiguration(rules => new ConfigRule[] + { + rules.For().FromFile(file.FilePath).Required(), + rules.For().FromLocalStorage(backend), + })); + + var provider = services.BuildServiceProvider(); + _disposables.Add(provider); + + var storage = provider.GetRequiredService>(); + var manager = provider.GetRequiredService(); + return (provider, storage, manager); + } + + [Fact] + public async Task Set_OverridesOnlyTouchedKey_OthersInherit_PascalCaseBase() + { + var (_, storage, manager) = Build("{\"Host\":\"smtp.default.com\",\"Port\":25,\"UseSsl\":false}"); + + await storage.SetAsync(x => x.Port, 587); + + await ActiveWaitHelpers.WaitUntilAsync( + () => manager.GetConfig()!.Port == 587, Timeout, description: "Port override"); + + var config = manager.GetConfig()!; + Assert.Equal(587, config.Port); + Assert.Equal("smtp.default.com", config.Host); // inherited + Assert.False(config.UseSsl); // inherited + } + + [Fact] + public async Task Set_AlignsToCamelCaseBase_NoSiblingKey() + { + // Base file uses camelCase keys; the override must land on them (Trap B), not create PascalCase siblings. + var (_, storage, manager) = Build("{\"host\":\"smtp.default.com\",\"port\":25}"); + + await storage.SetAsync(x => x.Port, 587); + + await ActiveWaitHelpers.WaitUntilAsync( + () => manager.GetConfig()!.Port == 587, Timeout, description: "camelCase override"); + + var config = manager.GetConfig()!; + Assert.Equal(587, config.Port); + Assert.Equal("smtp.default.com", config.Host); // still inherited (no ambiguous duplicate key) + } + + [Fact] + public async Task Reset_RestoresInheritedValue() + { + var (_, storage, manager) = Build("{\"Port\":25}"); + + await storage.SetAsync(x => x.Port, 587); + await ActiveWaitHelpers.WaitUntilAsync( + () => manager.GetConfig()!.Port == 587, Timeout, description: "override applied"); + + var removed = await storage.ResetAsync(x => x.Port); + Assert.True(removed); + + await ActiveWaitHelpers.WaitUntilAsync( + () => manager.GetConfig()!.Port == 25, Timeout, description: "reset to base"); + } + + [Fact] + public async Task ExplicitNull_ClobbersBase_DistinctFromReset() + { + var (_, storage, manager) = Build("{\"Host\":\"smtp.default.com\"}"); + + await storage.SetAsync(x => x.Host, null); + await ActiveWaitHelpers.WaitUntilAsync( + () => manager.GetConfig()!.Host is null, Timeout, description: "explicit null clobber"); + + await storage.ResetAsync(x => x.Host); + await ActiveWaitHelpers.WaitUntilAsync( + () => manager.GetConfig()!.Host == "smtp.default.com", Timeout, description: "reset restores"); + } + + [Fact] + public async Task SetToDefaultValue_StillOverrides() + { + // Base Port is 25; overriding to the C# default (0) must persist and win — the headline correctness win. + var (_, storage, manager) = Build("{\"Port\":25}"); + + await storage.SetAsync(x => x.Port, 0); + + await ActiveWaitHelpers.WaitUntilAsync( + () => manager.GetConfig()!.Port == 0, Timeout, description: "default-valued override"); + } + + [Fact] + public async Task Write_EmitsReactiveUpdate() + { + var (provider, storage, _) = Build("{\"Port\":25}"); + var reactive = provider.GetRequiredService>(); + + SmtpSettings? latest = null; + using var subscription = reactive.Subscribe(v => latest = v); + + await storage.SetAsync(x => x.Port, 587); + + await ActiveWaitHelpers.WaitUntilAsync( + () => latest?.Port == 587, Timeout, description: "reactive emission"); + } + + [Fact] + public async Task DescribeAsync_ReportsBaseEffectiveAndOverridden() + { + var (_, storage, manager) = Build("{\"Host\":\"smtp.default.com\",\"Port\":25}"); + + await storage.SetAsync(x => x.Port, 587); + await ActiveWaitHelpers.WaitUntilAsync( + () => manager.GetConfig()!.Port == 587, Timeout, description: "override applied"); + + var entries = await storage.DescribeAsync(); + + var port = Assert.Single(entries, e => e.KeyPath == "Port"); + Assert.True(port.IsOverridden); + Assert.Equal(25, port.BaseValue!.Value.GetInt32()); + Assert.Equal(587, port.EffectiveValue!.Value.GetInt32()); + + var host = Assert.Single(entries, e => e.KeyPath == "Host"); + Assert.False(host.IsOverridden); + Assert.Equal("smtp.default.com", host.BaseValue!.Value.GetString()); + Assert.Equal("smtp.default.com", host.EffectiveValue!.Value.GetString()); + } + + [Fact] + public async Task ReadAsync_ReturnsSparsePartial_ReadOverlay_ReturnsRaw() + { + var (_, storage, _) = Build("{\"Host\":\"smtp.default.com\",\"Port\":25}"); + + Assert.Null(await storage.ReadAsync()); // nothing overridden yet + + await storage.SetAsync(x => x.Port, 587); + + var partial = await storage.ReadAsync(); + Assert.NotNull(partial); + Assert.Equal(587, partial!.Port); + Assert.Null(partial.Host); // unset in the sparse overlay => C# default + + var raw = await storage.Overlay.ReadOverlayAsync(); + Assert.NotNull(raw); + Assert.Equal(587, raw!["Port"]!.GetValue()); + } + + [Fact] + public async Task Clear_RemovesAllOverrides() + { + var (_, storage, manager) = Build("{\"Port\":25}"); + + await storage.SetAsync(x => x.Port, 587); + await ActiveWaitHelpers.WaitUntilAsync( + () => manager.GetConfig()!.Port == 587, Timeout, description: "override applied"); + + await storage.ClearAsync(); + await ActiveWaitHelpers.WaitUntilAsync( + () => manager.GetConfig()!.Port == 25, Timeout, description: "cleared to base"); + + Assert.Null(await storage.ReadAsync()); + } + + [Fact] + public void BothInterfaces_ResolveToSameSingletonInstance() + { + var (provider, storage, _) = Build("{\"Port\":25}"); + + var overlay = provider.GetRequiredService>(); + var storageAgain = provider.GetRequiredService>(); + + Assert.Same(storage, storageAgain); // singleton + Assert.Same(storage.Overlay, overlay); // overlay resolves to the same adapter + Assert.True(ReferenceEquals(storage, overlay)); // one object implements both + } + + public void Dispose() + { + for (var i = _disposables.Count - 1; i >= 0; i--) + { + try { _disposables[i].Dispose(); } catch { /* best effort */ } + } + } +} diff --git a/src/tests/Cocoar.Configuration.Providers.Tests/LocalStorage/LocalStorageSecretEnvelopeTests.cs b/src/tests/Cocoar.Configuration.Providers.Tests/LocalStorage/LocalStorageSecretEnvelopeTests.cs new file mode 100644 index 0000000..3bf58bb --- /dev/null +++ b/src/tests/Cocoar.Configuration.Providers.Tests/LocalStorage/LocalStorageSecretEnvelopeTests.cs @@ -0,0 +1,118 @@ +using System.Security.Cryptography.X509Certificates; +using System.Text.Json; +using System.Text.Json.Nodes; +using Cocoar.Configuration.Core; +using Cocoar.Configuration.DI; +using Cocoar.Configuration.LocalStorage; +using Cocoar.Configuration.Providers; +using Cocoar.Configuration.Providers.Tests.TestUtilities; +using Cocoar.Configuration.Rules; +using Cocoar.Configuration.Secrets; +using Cocoar.Configuration.Secrets.SecretTypes; +using Cocoar.Configuration.X509Encryption; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Cocoar.Configuration.Providers.Tests.LocalStorage; + +[Trait("Type", "Unit")] +public sealed class LocalStorageSecretEnvelopeTests +{ + public sealed class VaultConfig + { + public Secret? ApiKey { get; set; } + } + + [Fact] + public async Task SetSecretAsync_StoresEncryptedEnvelope_DecryptsToOriginalValue() + { + const string kid = "test-kid"; + var pfxPath = Path.Combine(Path.GetTempPath(), "cocoar_secret_" + Guid.NewGuid().ToString("N") + ".pfx"); + using var cert = X509CertificateGenerator.GenerateAndSavePfx(pfxPath, password: null, "CN=Cocoar Test", overwrite: true); + try + { + // Encrypt CLIENT-SIDE (here: with the cert) — the server only ever receives the envelope. + var envelope = BuildEnvelope(cert, kid, "super-secret-value"); + var backend = new InMemoryBackend(); + + var services = new ServiceCollection(); + services.AddCocoarConfiguration(c => c + .UseConfiguration(rules => new ConfigRule[] + { + rules.For().FromStaticJson("{}"), + rules.For().FromLocalStorage(backend), + }) + .UseSecretsSetup(secrets => secrets.UseCertificateFromFile(pfxPath).WithKeyId(kid))); + + using var provider = services.BuildServiceProvider(); + var storage = provider.GetRequiredService>(); + var manager = provider.GetRequiredService(); + + await storage.SetSecretAsync(x => x.ApiKey!, envelope); + + await ActiveWaitHelpers.WaitUntilAsync( + () => manager.GetConfig()?.ApiKey is not null, + TimeSpan.FromSeconds(5), description: "encrypted secret override applied"); + + var config = manager.GetConfig()!; + using var lease = config.ApiKey!.Open(); + Assert.Equal("super-secret-value", lease.Value); + } + finally + { + if (System.IO.File.Exists(pfxPath)) System.IO.File.Delete(pfxPath); + } + } + + [Fact] + public async Task SetSecretEnvelopeAsync_RejectsPlaintext() + { + using var provider = BuildMinimalProvider(); + var overlay = provider.GetRequiredService>(); + + // A bare string is not a cocoar.secret envelope → rejected before anything is stored. + await Assert.ThrowsAsync( + () => overlay.SetSecretEnvelopeAsync("ApiKey", JsonValue.Create("plaintext-leak"))); + } + + [Fact] + public void SetAsync_OnSecretMember_StillThrowsNotSupported() + { + using var provider = BuildMinimalProvider(); + var storage = provider.GetRequiredService>(); + + // The normal typed SetAsync must keep rejecting secret members (no plaintext into the overlay). + Assert.Throws( + () => { _ = storage.SetAsync(x => x.ApiKey!, Secret.FromPlain("x")); }); + } + + private static ServiceProvider BuildMinimalProvider() + { + var backend = new InMemoryBackend(); + var services = new ServiceCollection(); + services.AddCocoarConfiguration(c => c.UseConfiguration(rules => new ConfigRule[] + { + rules.For().FromStaticJson("{}"), + rules.For().FromLocalStorage(backend), + })); + return services.BuildServiceProvider(); + } + + private static SecretEnvelope BuildEnvelope(X509Certificate2 cert, string kid, string value) + { + var hybrid = new X509HybridCrypto(cert).Encrypt(JsonSerializer.Serialize(value)); + // The runtime decrypt path (HybridEnvelope byte[] fields) reads base64url WITHOUT padding — this is + // exactly what a browser must emit. (X509HybridCrypto produces standard base64, so we convert.) + return new SecretEnvelope + { + Kid = kid, + Wk = ToBase64Url(hybrid.WrappedKey), + Iv = ToBase64Url(hybrid.Iv), + Ct = ToBase64Url(hybrid.Ciphertext), + Tag = ToBase64Url(hybrid.Tag), + }; + } + + private static string ToBase64Url(string standardBase64) + => standardBase64.Replace('+', '-').Replace('/', '_').TrimEnd('='); +} diff --git a/src/tests/Cocoar.Configuration.Providers.Tests/LocalStorage/OverlayPathResolverTests.cs b/src/tests/Cocoar.Configuration.Providers.Tests/LocalStorage/OverlayPathResolverTests.cs new file mode 100644 index 0000000..b659b8e --- /dev/null +++ b/src/tests/Cocoar.Configuration.Providers.Tests/LocalStorage/OverlayPathResolverTests.cs @@ -0,0 +1,56 @@ +using Cocoar.Configuration.Providers; +using Xunit; + +namespace Cocoar.Configuration.Providers.Tests.LocalStorage; + +public class OverlayPathResolverTests +{ + [Fact] + [Trait("Type", "Unit")] + public void ResolveKeyPath_SimpleMemberChain() + { + var path = OverlayPathResolver.ResolveKeyPath(x => x.Nested.Count); + Assert.Equal("Nested.Count", path); + } + + [Fact] + [Trait("Type", "Unit")] + public void ResolveKeyPath_TopLevelMember() + { + var path = OverlayPathResolver.ResolveKeyPath(x => x.Port); + Assert.Equal("Port", path); + } + + [Fact] + [Trait("Type", "Unit")] + public void ResolveKeyPath_HonorsJsonPropertyName() + { + var path = OverlayPathResolver.ResolveKeyPath(x => x.Renamed); + Assert.Equal("custom_name", path); + } + + [Fact] + [Trait("Type", "Unit")] + public void ResolveKeyPath_MethodCall_Throws() + { + Assert.Throws(() => + OverlayPathResolver.ResolveKeyPath(x => x.Host!.ToUpperInvariant())); + } + + [Fact] + [Trait("Type", "Unit")] + public void ResolveKeyPath_Indexer_Throws() + { + Assert.Throws(() => + OverlayPathResolver.ResolveKeyPath(x => x.Items[0])); + } + + [Fact] + [Trait("Type", "Unit")] + public void ResolveKeyPath_SecretMember_Throws() + { + var ex = Assert.Throws(() => + OverlayPathResolver.ResolveKeyPath(x => x.ApiKey)); + Assert.Contains("secret", ex.Message, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/src/tests/Cocoar.Configuration.Providers.Tests/LocalStorage/OverlayTestSupport.cs b/src/tests/Cocoar.Configuration.Providers.Tests/LocalStorage/OverlayTestSupport.cs new file mode 100644 index 0000000..4787030 --- /dev/null +++ b/src/tests/Cocoar.Configuration.Providers.Tests/LocalStorage/OverlayTestSupport.cs @@ -0,0 +1,54 @@ +using System.Text.Json.Serialization; +using Cocoar.Configuration.Providers; +using Cocoar.Configuration.Secrets.SecretTypes; + +namespace Cocoar.Configuration.Providers.Tests.LocalStorage; + +public sealed class SmtpSettings +{ + public string? Host { get; set; } + public int Port { get; set; } + public bool UseSsl { get; set; } + public NestedSettings Nested { get; set; } = new(); +} + +public sealed class NestedSettings +{ + public string? Level { get; set; } + public int Count { get; set; } +} + +public sealed class AttributedSettings +{ + [JsonPropertyName("custom_name")] + public string? Renamed { get; set; } +} + +public sealed class SecretSettings +{ + public Secret? ApiKey { get; set; } + public string? Plain { get; set; } +} + +public sealed class IndexableSettings +{ + public List Items { get; set; } = new(); +} + +/// In-memory storage backend for deterministic overlay tests (no file I/O). +public sealed class InMemoryBackend : IStorageBackend +{ + private byte[]? _data; + + public InMemoryBackend(byte[]? seed = null) => _data = seed; + + public byte[]? Current => _data; + + public Task ReadAsync(string key, CancellationToken ct = default) => Task.FromResult(_data); + + public Task WriteAsync(string key, byte[] data, CancellationToken ct = default) + { + _data = data; + return Task.CompletedTask; + } +} diff --git a/src/tests/Cocoar.Configuration.Providers.Tests/LocalStorage/SparseOverlayMutatorTests.cs b/src/tests/Cocoar.Configuration.Providers.Tests/LocalStorage/SparseOverlayMutatorTests.cs new file mode 100644 index 0000000..c92aadf --- /dev/null +++ b/src/tests/Cocoar.Configuration.Providers.Tests/LocalStorage/SparseOverlayMutatorTests.cs @@ -0,0 +1,161 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using Cocoar.Configuration.Providers; +using Cocoar.Json.Mutable; +using Xunit; + +namespace Cocoar.Configuration.Providers.Tests.LocalStorage; + +public class SparseOverlayMutatorTests +{ + private static byte[] Empty => "{}"u8.ToArray(); + + private static MutableJsonObject Base(string json) + => (MutableJsonObject)MutableJsonDocument.Parse(Encoding.UTF8.GetBytes(json)); + + private static JsonElement Parse(byte[] bytes) + { + using var doc = JsonDocument.Parse(bytes); + return doc.RootElement.Clone(); + } + + [Fact] + [Trait("Type", "Unit")] + public void Set_CreatesNestedSparsePath_OnlyTouchedLeaf() + { + var result = SparseOverlayMutator.Set(Empty, "Nested.Count", JsonValue.Create(7), baseDom: null); + + var root = Parse(result); + Assert.Equal(7, root.GetProperty("Nested").GetProperty("Count").GetInt32()); + // Only the touched leaf is present — no sibling defaults leaked in. + Assert.Single(EnumerateNames(root)); + Assert.Single(EnumerateNames(root.GetProperty("Nested"))); + } + + [Fact] + [Trait("Type", "Unit")] + public void Set_AlignsCasingToBase_CamelCaseBase() + { + var baseDom = Base("{\"smtp\":{\"port\":25}}"); + + var result = SparseOverlayMutator.Set(Empty, "Smtp.Port", JsonValue.Create(587), baseDom); + + var root = Parse(result); + // The override must land on the base's exact key casing, not create PascalCase siblings. + Assert.True(root.TryGetProperty("smtp", out var smtp)); + Assert.Equal(587, smtp.GetProperty("port").GetInt32()); + Assert.False(root.TryGetProperty("Smtp", out _)); + } + + [Fact] + [Trait("Type", "Unit")] + public void Set_DescendsBaseByBaseKey_NotDriftedOverlayKey() + { + // The overlay already holds an intermediate key that is byte-different from the base's key + // (case-insensitively equal). The base descent must still follow the BASE's casing so the new + // leaf aligns to the base layer's deeper key, independent of the drifted overlay key's casing. + var seeded = Encoding.UTF8.GetBytes("{\"Smtp\":{\"host\":\"h\"}}"); + var baseDom = Base("{\"smtp\":{\"port\":25}}"); + + var result = SparseOverlayMutator.Set(seeded, "Smtp.Port", JsonValue.Create(587), baseDom); + + var root = Parse(result); + // Leaf aligned to the base's "port" casing — not the resolver default "Port". + Assert.Equal(587, root.GetProperty("Smtp").GetProperty("port").GetInt32()); + Assert.False(root.GetProperty("Smtp").TryGetProperty("Port", out _)); + } + + [Fact] + [Trait("Type", "Unit")] + public void Set_ExplicitNull_WritesJsonNull() + { + var result = SparseOverlayMutator.Set(Empty, "Host", valueNode: null, baseDom: null); + + var root = Parse(result); + Assert.Equal(JsonValueKind.Null, root.GetProperty("Host").ValueKind); + } + + [Fact] + [Trait("Type", "Unit")] + public void Set_DefaultValuedLeaf_IsPersisted() + { + var result = SparseOverlayMutator.Set(Empty, "Port", JsonValue.Create(0), baseDom: null); + + var root = Parse(result); + Assert.True(root.TryGetProperty("Port", out var port)); + Assert.Equal(0, port.GetInt32()); + } + + [Fact] + [Trait("Type", "Unit")] + public void Set_SecondLeafUnderSameParent_KeepsBoth() + { + var first = SparseOverlayMutator.Set(Empty, "Smtp.Port", JsonValue.Create(587), baseDom: null); + var second = SparseOverlayMutator.Set(first, "Smtp.Host", JsonValue.Create("h"), baseDom: null); + + var root = Parse(second); + Assert.Equal(587, root.GetProperty("Smtp").GetProperty("Port").GetInt32()); + Assert.Equal("h", root.GetProperty("Smtp").GetProperty("Host").GetString()); + } + + [Fact] + [Trait("Type", "Unit")] + public void Remove_PrunesEmptyAncestors() + { + var seeded = Encoding.UTF8.GetBytes("{\"Smtp\":{\"Port\":587}}"); + + var (bytes, removed) = SparseOverlayMutator.Remove(seeded, "Smtp.Port"); + + Assert.True(removed); + Assert.Equal("{}", Encoding.UTF8.GetString(bytes)); + } + + [Fact] + [Trait("Type", "Unit")] + public void Remove_KeepsSiblings() + { + var seeded = Encoding.UTF8.GetBytes("{\"Smtp\":{\"Port\":587,\"Host\":\"x\"}}"); + + var (bytes, removed) = SparseOverlayMutator.Remove(seeded, "Smtp.Port"); + + Assert.True(removed); + var root = Parse(bytes); + Assert.False(root.GetProperty("Smtp").TryGetProperty("Port", out _)); + Assert.Equal("x", root.GetProperty("Smtp").GetProperty("Host").GetString()); + } + + [Fact] + [Trait("Type", "Unit")] + public void Remove_AbsentKey_IsNoOp() + { + var seeded = Encoding.UTF8.GetBytes("{\"Smtp\":{\"Port\":587}}"); + + var (bytes, removed) = SparseOverlayMutator.Remove(seeded, "Other.Key"); + + Assert.False(removed); + Assert.Same(seeded, bytes); + } + + [Fact] + [Trait("Type", "Unit")] + public void Remove_CaseInsensitiveMatch_Removes() + { + var seeded = Encoding.UTF8.GetBytes("{\"smtp\":{\"port\":587}}"); + + var (bytes, removed) = SparseOverlayMutator.Remove(seeded, "Smtp.Port"); + + Assert.True(removed); + Assert.Equal("{}", Encoding.UTF8.GetString(bytes)); + } + + private static List EnumerateNames(JsonElement obj) + { + var names = new List(); + foreach (var p in obj.EnumerateObject()) + { + names.Add(p.Name); + } + return names; + } +} diff --git a/src/tests/Cocoar.Configuration.Providers.Tests/SecretEncryptionKeyProviderTests.cs b/src/tests/Cocoar.Configuration.Providers.Tests/SecretEncryptionKeyProviderTests.cs new file mode 100644 index 0000000..8c4246a --- /dev/null +++ b/src/tests/Cocoar.Configuration.Providers.Tests/SecretEncryptionKeyProviderTests.cs @@ -0,0 +1,200 @@ +using System.Security.Cryptography; +using System.Text.Json; +using Cocoar.Configuration.Core; +using Cocoar.Configuration.DI; +using Cocoar.Configuration.LocalStorage; +using Cocoar.Configuration.Providers; +using Cocoar.Configuration.Providers.Tests.LocalStorage; +using Cocoar.Configuration.Providers.Tests.TestUtilities; +using Cocoar.Configuration.Rules; +using Cocoar.Configuration.Secrets; +using Cocoar.Configuration.Secrets.SecretTypes; +using Cocoar.Configuration.X509Encryption; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Cocoar.Configuration.Providers.Tests; + +/// +/// Verifies the secrets encryption-key publishing accessor: it exposes the configured single kid's +/// current PUBLIC key (SPKI, base64url-no-padding), and a producer holding only that public key can +/// build an envelope the server decrypts — without ever touching the private cert. +/// +[Trait("Type", "Unit")] +public sealed class SecretEncryptionKeyProviderTests +{ + public sealed class VaultConfig + { + public Secret? ApiKey { get; set; } + } + + [Fact] + public void GetCurrentKeys_SingleKid_PublishesOnePublicKey() + { + const string kid = "publish-kid"; + RunWithCert(kid, provider => + { + var keyProvider = provider.GetRequiredService(); + + var key = Assert.Single(keyProvider.GetCurrentKeys()); + + Assert.Equal(kid, key.Kid); + Assert.Equal(SecretAlgorithms.Hybrid, key.Alg); + Assert.Equal(SecretAlgorithms.KeyWrap, key.Walg); + Assert.Equal(SecretAlgorithms.DataEncryption, key.Enc); + Assert.Equal("spki", key.Format); + Assert.Equal("base64url", key.Encoding); + Assert.False(string.IsNullOrEmpty(key.PublicKey)); + + // base64url WITHOUT padding — never standard-base64 characters. + Assert.DoesNotContain('+', key.PublicKey); + Assert.DoesNotContain('/', key.PublicKey); + Assert.DoesNotContain('=', key.PublicKey); + + // The published bytes are a valid SubjectPublicKeyInfo. + using var rsa = RSA.Create(); + rsa.ImportSubjectPublicKeyInfo(FromBase64Url(key.PublicKey), out _); + }); + } + + [Fact] + public void GetCurrentKey_ReturnsKeyForConfiguredKid_NullOtherwise() + { + const string kid = "lookup-kid"; + RunWithCert(kid, provider => + { + var keyProvider = provider.GetRequiredService(); + + Assert.NotNull(keyProvider.GetCurrentKey(kid)); + Assert.Null(keyProvider.GetCurrentKey("not-configured")); + Assert.Null(keyProvider.GetCurrentKey("")); + }); + } + + [Fact] + public void NoSecretsConfigured_ProviderNotRegistered() + { + var services = new ServiceCollection(); + services.AddCocoarConfiguration(c => c.UseConfiguration(_ => Array.Empty())); + using var provider = services.BuildServiceProvider(); + + Assert.Null(provider.GetService()); + } + + [Fact] + public async Task PublishedPublicKey_EncryptsValue_ServerDecryptsToOriginal() + { + const string kid = "roundtrip-kid"; + const string secretValue = "round-trip-secret"; + + var pfxPath = NewPfxPath(); + using var cert = X509CertificateGenerator.GenerateAndSavePfx(pfxPath, password: null, "CN=Cocoar Test", overwrite: true); + try + { + var backend = new InMemoryBackend(); + var services = new ServiceCollection(); + services.AddCocoarConfiguration(c => c + .UseConfiguration(rules => new ConfigRule[] + { + rules.For().FromStaticJson("{}"), + rules.For().FromLocalStorage(backend), + }) + .UseSecretsSetup(secrets => secrets.UseCertificateFromFile(pfxPath).WithKeyId(kid))); + + using var provider = services.BuildServiceProvider(); + + // 1. Fetch ONLY the published public key — the producer never sees the private cert. + var published = provider.GetRequiredService().GetCurrentKey(kid); + Assert.NotNull(published); + + // 2. Encrypt client-side with that public key alone (what a browser does). + using var rsaPublic = RSA.Create(); + rsaPublic.ImportSubjectPublicKeyInfo(FromBase64Url(published!.PublicKey), out _); + var envelope = EncryptWithPublicKey(rsaPublic, kid, secretValue); + + // 3. Server stores the envelope and decrypts with the matching private key. + var storage = provider.GetRequiredService>(); + var manager = provider.GetRequiredService(); + await storage.SetSecretAsync(x => x.ApiKey!, envelope); + + await ActiveWaitHelpers.WaitUntilAsync( + () => manager.GetConfig()?.ApiKey is not null, + TimeSpan.FromSeconds(5), description: "published-key envelope applied"); + + using var lease = manager.GetConfig()!.ApiKey!.Open(); + Assert.Equal(secretValue, lease.Value); + } + finally + { + if (System.IO.File.Exists(pfxPath)) System.IO.File.Delete(pfxPath); + } + } + + private static void RunWithCert(string kid, Action test) + { + var pfxPath = NewPfxPath(); + using var cert = X509CertificateGenerator.GenerateAndSavePfx(pfxPath, password: null, "CN=Cocoar Test", overwrite: true); + try + { + var services = new ServiceCollection(); + services.AddCocoarConfiguration(c => c + .UseConfiguration(_ => Array.Empty()) + .UseSecretsSetup(secrets => secrets.UseCertificateFromFile(pfxPath).WithKeyId(kid))); + + using var provider = services.BuildServiceProvider(); + test(provider); + } + finally + { + if (System.IO.File.Exists(pfxPath)) System.IO.File.Delete(pfxPath); + } + } + + private static string NewPfxPath() + => Path.Combine(Path.GetTempPath(), "cocoar_pubkey_" + Guid.NewGuid().ToString("N") + ".pfx"); + + private static SecretEnvelope EncryptWithPublicKey(RSA rsaPublic, string kid, string value) + { + var plaintext = JsonSerializer.SerializeToUtf8Bytes(value); + + Span dek = stackalloc byte[32]; + RandomNumberGenerator.Fill(dek); + try + { + var iv = new byte[12]; + RandomNumberGenerator.Fill(iv); + var ct = new byte[plaintext.Length]; + var tag = new byte[16]; + + using (var aes = new AesGcm(dek, tag.Length)) + { + aes.Encrypt(iv, plaintext, ct, tag, associatedData: null); + } + + var wk = rsaPublic.Encrypt(dek.ToArray(), RSAEncryptionPadding.OaepSHA256); + + return new SecretEnvelope + { + Kid = kid, + Wk = ToBase64Url(wk), + Iv = ToBase64Url(iv), + Ct = ToBase64Url(ct), + Tag = ToBase64Url(tag), + }; + } + finally + { + CryptographicOperations.ZeroMemory(dek); + } + } + + private static string ToBase64Url(byte[] bytes) + => Convert.ToBase64String(bytes).Replace('+', '-').Replace('/', '_').TrimEnd('='); + + private static byte[] FromBase64Url(string value) + { + var b64 = value.Replace('-', '+').Replace('_', '/'); + b64 += (b64.Length % 4) switch { 2 => "==", 3 => "=", _ => string.Empty }; + return Convert.FromBase64String(b64); + } +} diff --git a/website/.vitepress/config.ts b/website/.vitepress/config.ts index e4703ef..03cfe83 100644 --- a/website/.vitepress/config.ts +++ b/website/.vitepress/config.ts @@ -66,6 +66,7 @@ export default defineConfig({ { text: 'HTTP Polling', link: '/guide/providers/http-polling' }, { text: 'Microsoft IConfiguration', link: '/guide/providers/microsoft-adapter' }, { text: 'Static & Observable', link: '/guide/providers/static-observable' }, + { text: 'LocalStorage', link: '/guide/providers/localstorage' }, { text: 'Custom Providers ', link: '/guide/providers/custom' }, ], }, diff --git a/website/changelog.md b/website/changelog.md index 6cf1e5e..0fe757b 100644 --- a/website/changelog.md +++ b/website/changelog.md @@ -2,6 +2,19 @@ ## [Unreleased] +### Added + +**LocalStorage — writable override layer** +- A writable, application-controlled layer for *overridable defaults*: the normal sources supply defaults; the app overrides individual values at runtime. +- `ILocalStorage` (type-safe facade) and `ILocalStorageOverlay` (raw key-path surface) in `Cocoar.Configuration.Abstractions` +- **Sparse writes** — `SetAsync(x => x.Smtp.Port, value)` persists only the touched leaf; unset keys keep inheriting from lower layers +- `ResetAsync(...)` removes an override (falls back to the inherited default); an explicit `null` override is distinct from reset +- `DescribeAsync()` returns per-key provenance (`OverrideEntry`: base, effective, `IsOverridden`) for management UIs +- `.FromLocalStorage()` rule extension; file-based backend by default, pluggable `IStorageBackend` +- `ILocalStorage` / `ILocalStorageOverlay` are DI-injectable (single shared singleton) — write your own endpoints with your own validation/normalization/logging +- Secret-typed members cannot be overridden via LocalStorage (throws `NotSupportedException`) +- `IProviderServiceRegistration` gained resolve-time factory registration support + ### Changed **Secrets — robust enum & casing handling** diff --git a/website/guide/providers/localstorage.md b/website/guide/providers/localstorage.md new file mode 100644 index 0000000..0fd506f --- /dev/null +++ b/website/guide/providers/localstorage.md @@ -0,0 +1,182 @@ +# LocalStorage Provider + +LocalStorage is a **writable, application-controlled override layer**. Every other provider is an external source you read from — LocalStorage is the one layer your application can write to at runtime. + +Its purpose is **overridable defaults**: the normal sources (files, environment, …) supply defaults, and the application overrides *individual* values at runtime — from an admin UI, an API, or a background job — while everything it doesn't touch keeps inheriting from the lower layers. + +```csharp +rules => +[ + rules.For().FromFile("appsettings.json"), // defaults + rules.For().FromLocalStorage(), // app-controlled overrides (placed last → wins) +] +``` + +Position matters: place the LocalStorage rule **after** the rules whose values it should override. + +## Sparse overrides + +LocalStorage persists a **sparse** JSON object — only the leaves you explicitly set. Everything else is physically absent and therefore inherits from the lower layers through the normal byte-level merge. + +Given the defaults `{ "Host": "smtp.default.com", "Port": 25, "UseSsl": false }`, after: + +```csharp +await storage.SetAsync(x => x.Port, 587); +await storage.SetAsync(x => x.UseSsl, true); +``` + +the persisted overlay is just: + +```json +{ "Port": 587, "UseSsl": true } +``` + +and the effective configuration is `Host=smtp.default.com` (inherited), `Port=587`, `UseSsl=true`. + +This is the key difference from a "save the whole object" store: setting one value never freezes the others. If a default changes in the file later, every key you didn't override picks it up. + +## Reading and writing + +Inject `ILocalStorage` (registered as a **Singleton**, thread-safe) to override values at runtime: + +```csharp +public class SettingsController(ILocalStorage storage) +{ + // Override a single value — only this leaf is persisted; a recompute fires + // and IReactiveConfig emits the new effective value. + public Task SetPort(int port) => storage.SetAsync(x => x.Port, port); + + // Reset one override — the value falls back to the inherited default. + public Task ResetPort() => storage.ResetAsync(x => x.Port); + + // Clear every override this layer holds. + public Task ResetAll() => storage.ClearAsync(); +} +``` + +The selector must be a **simple member-access chain** (`x => x.Smtp.Port`). Indexers, method calls, and casts throw `NotSupportedException` — use the raw [overlay surface](#raw-overlay-surface) for dynamic paths. + +::: tip Writes are reactive +A write persists to storage, signals the provider, and triggers a (debounced) recompute. Subscribers of `IReactiveConfig` receive the new merged value automatically. +::: + +### Reset vs. explicit null + +These are deliberately different operations: + +| Operation | Overlay result | Effective value | +|---|---|---| +| `ResetAsync(x => x.Host)` | key removed | **inherits** the lower-layer value | +| `SetAsync(x => x.Host, null)` | `{ "Host": null }` | **overridden to `null`** (clobbers the base) | + +### Overriding to a default-looking value + +Because only touched keys are persisted, overriding to a value that happens to equal the C# default still counts as an override: + +```csharp +await storage.SetAsync(x => x.Port, 0); // persists {"Port":0} → effective Port is 0, even if the base was 25 +``` + +This is the headline correctness win: "an admin chose `0`" is distinct from "nobody set it." + +### Reading the overlay + +```csharp +SmtpSettings? overrides = await storage.ReadAsync(); // sparse partial T (unset members = C# defaults), null if empty +JsonNode? raw = await storage.Overlay.ReadOverlayAsync(); // the raw stored fragment, null if empty +``` + +`ReadAsync` returns only what the overlay holds — **not** the merged result. For the effective value use `IReactiveConfig.CurrentValue` or `IConfigurationAccessor.GetConfig()`. + +## Provenance for a management UI + +`DescribeAsync()` returns, per key, the base value, the effective value, and whether it is currently overridden — everything a "default vs. override, with reset" UI needs: + +```csharp +foreach (var entry in await storage.DescribeAsync()) +{ + // entry.KeyPath, entry.BaseValue, entry.EffectiveValue, entry.IsOverridden +} +``` + +| KeyPath | BaseValue | EffectiveValue | IsOverridden | +|---|---|---|---| +| `Host` | `"smtp.default.com"` | `"smtp.default.com"` | `false` | +| `Port` | `25` | `587` | `true` | +| `UseSsl` | `false` | `true` | `true` | + +`BaseValue` is the value computed from the layers **below** this overlay — i.e. what the key would be if the override were removed. + +## Raw overlay surface + +For dynamic or non-expressible paths, use `ILocalStorageOverlay` (also resolvable directly from DI, or via `storage.Overlay`). Key paths are dotted; their segments must match the persisted JSON property names: + +```csharp +await storage.Overlay.SetAsync("Smtp.Port", JsonValue.Create(587)); +await storage.Overlay.ResetAsync("Smtp.Port"); +``` + +The typed facade aligns key casing to the lower layers for you; with the raw surface that responsibility is yours. Do **not** use the raw surface for secret paths. + +## Arrays and secrets + +- **Arrays are replaced wholesale.** `SetAsync(x => x.Hosts, list)` overrides the entire array — there is no element-level merge. Per-element selectors (`x => x.Hosts[2]`) are rejected. +- **Secrets are not overridable.** Members typed as `Secret` / `ISecret` throw `NotSupportedException` on the typed facade — an overlay write would replace the encrypted secret with a mask. Manage secrets via the Secrets CLI/provider. + +## Writing your own endpoints + +There is no built-in REST surface — and that's deliberate. Writes are where *your* rules live (validation, normalization, authorization, audit logging, request shape), so the library gives you the injectable primitive and you own the endpoint. Inject `ILocalStorage` (or `ILocalStorageOverlay`) anywhere and do your work *before* writing: + +```csharp +app.MapPut("/admin/smtp/port", async ( + int port, + ILocalStorage storage, + ILogger log) => +{ + if (port is < 1 or > 65535) // validate + return Results.BadRequest("Port must be 1–65535."); + + log.LogInformation("Admin override SMTP.Port = {Port}", port); // audit + await storage.SetAsync(x => x.Port, port); // then persist (sparse) → recompute → reactive emit + return Results.NoContent(); +}) +.RequireAuthorization("AdminPolicy"); + +// Expose the provenance view for a management UI: +app.MapGet("/admin/smtp", (ILocalStorage storage, CancellationToken ct) => + storage.DescribeAsync(ct)); +``` + +For a generic admin UI that sets arbitrary keys, inject the raw `ILocalStorageOverlay` and pass the dotted key path and a `JsonNode` yourself (your code is responsible for validating the path and value). + +Both `ILocalStorage` and `ILocalStorageOverlay` are registered by `AddCocoarConfiguration` as the **same** singleton instance, so either can be injected into controllers or minimal-API handlers. + +## Storage backends + +By default, overrides are persisted as a JSON file under `{AppContext.BaseDirectory}/.cocoar/localStorage/`, written atomically (temp-file-then-rename). Plug in your own store by implementing `IStorageBackend`: + +```csharp +rules.For().FromLocalStorage(new MyDatabaseBackend()); +``` + +A config-aware overload receives the current configuration and backend, for backends whose connection depends on earlier rules (e.g. a connection string): + +```csharp +rules.For().FromLocalStorage((accessor, current) => + current ?? new DbBackend(accessor.GetConfig()!.ConnectionString)); +``` + +`ReadAsync` returns empty `{}` when nothing is stored (consistent with the [provider contract](/guide/providers/overview#the-provider-contract)), so an unwritten overlay is an invisible layer. + +## How it works + +``` +ILocalStorage.SetAsync(x => x.Port, 587) + → resolve "Port" to a dotted key path + align casing to the lower layers + → atomically read-merge-write the sparse overlay leaf to the backend + → signal the provider's change observable + → engine recompute (debounced) merges layers byte-for-byte + → IReactiveConfig emits the new effective value +``` + +The read/merge path is identical to every other provider — LocalStorage only adds the write path. See the runnable [LocalStorageOverride example](https://github.com/) for an end-to-end walkthrough. diff --git a/website/guide/providers/overview.md b/website/guide/providers/overview.md index fd7d767..2c8e055 100644 --- a/website/guide/providers/overview.md +++ b/website/guide/providers/overview.md @@ -29,6 +29,7 @@ On failure, providers return an empty JSON object `{}` — never null. This mean | [Command Line](/guide/providers/command-line) | `.FromCommandLine("--prefix")` | No | Core | | [Static JSON](/guide/providers/static-observable#static-json) | `.FromStaticJson("{...}")` | No | Core | | [Observable](/guide/providers/static-observable#observable) | `.FromObservable(obs)` | Yes | Core | +| [LocalStorage](/guide/providers/localstorage) | `.FromLocalStorage()` | Yes (on write) | Core | | [HTTP](/guide/providers/http-polling) | `.FromHttp(url)` | Polling / SSE / one-time | Http | | [Microsoft IConfiguration](/guide/providers/microsoft-adapter) | `.FromIConfiguration(config)` | IConfiguration reload token | MicrosoftAdapter |