diff --git a/ZB.MOM.WW.Secrets/CHANGELOG.md b/ZB.MOM.WW.Secrets/CHANGELOG.md index 2cac8aa..2fd5711 100644 --- a/ZB.MOM.WW.Secrets/CHANGELOG.md +++ b/ZB.MOM.WW.Secrets/CHANGELOG.md @@ -5,6 +5,64 @@ follows [Keep a Changelog](https://keepachangelog.com/); versions are shared acr package in the family (see `Directory.Build.props`) even when a given release only touches one of them. +## 0.6.0 — 2026-08-11 + +### Added + +- **`ZB.MOM.WW.Secrets` — fail-closed validation of the store path.** `Secrets:SqlitePath` is + now validated at startup (`ValidateOnStart`) by `SecretsOptionsValidator`, using two rules in + the new public `SecretsStorePathRules`. Both come from production incidents and neither + implies the other: + - **Must be absolute.** A relative value resolves against the process working directory — + for a Windows service that is `C:\Windows\System32`, so the store lands outside the + installation entirely; in a dev loop it lands in the source tree (observed: a gateway wrote + its secrets database into `src/` and tripped that repo's own tree-hygiene test). + - **Must not be inside the application content root.** An *absolute* path can still point + into the directory every deployment procedure in this family replaces or renames on + upgrade. On 2026-08-09 a gateway's credential store sat inside its `Server\` directory; the + deploy renamed `Server` to `Server.bak.*`, the database left with it, and the process + created a fresh empty one at the same path. Every consumer lost its credential, silently, + for two days. The existing "absolute path" convention did not help — the path *was* + absolute. + + The rules **reject rather than relocate**: silently moving a credential store presents later + as an authentication failure pointing nowhere near the configuration that caused it, whereas + a boot error is seen immediately by whoever caused it. Validation is deliberately **not** + exempted in Development — a guard that never runs in a dev loop or in CI rots, and the + configuration behind the outage looked entirely reasonable. + + The content-root rule is skipped when no `IHostEnvironment` is available (the CLI composes + the subsystem without a host); the absolute-path rule still applies. + +### Changed + +- **BREAKING (configuration, not API): `SecretsOptions.SqlitePath` now defaults to an absolute + per-user path** — `ZB.MOM.WW/secrets.db` under `LocalApplicationData` (`~/.local/share` on + Linux and macOS, `%LOCALAPPDATA%` on Windows), exposed as `SecretsOptions.DefaultSqlitePath`. + It was `secrets.db`, which the new rules reject. + + **A consuming application that configures a relative `Secrets:SqlitePath` will now fail to + boot.** The fix is to remove the key (accepting the default) or set an absolute path outside + the content root. Every deployment of this family already sets an absolute path, so this + bites dev loops and unconfigured installs, not production — but verify before upgrading: a + host that relied on the *old* default now looks in a new location, finds nothing, and creates + an empty store, which is the same failure this release exists to prevent. + + Per-user rather than machine-wide is deliberate. `CommonApplicationData` is `/usr/share` on + Linux and macOS, which a normal user cannot write; as a library default that would fail every + consuming application's boot on a developer machine. An application is free to compute its own + default (a machine-wide Windows service reasonably prefers `CommonApplicationData` alongside + its other stores) — the configured value always wins. + +- `AddZbSecrets` registration of the validator is idempotent. `AddZbSecrets` is legitimately + called more than once in real compositions (a replication package calls it after the + application already has), and `TryAddEnumerable` cannot de-duplicate a factory-built + descriptor — it de-duplicates on implementation type, which a factory does not carry, so it + throws on the second call rather than skipping. + +- `ZB.MOM.WW.Secrets` now depends on `ZB.MOM.WW.Configuration` (0.1.0) for the shared + `OptionsValidatorBase`/`ValidationBuilder` vocabulary. Every consumer already references it. + ## 0.5.1 — 2026-08-07 ### Fixed diff --git a/ZB.MOM.WW.Secrets/Directory.Build.props b/ZB.MOM.WW.Secrets/Directory.Build.props index 9f00eb1..500b2c2 100644 --- a/ZB.MOM.WW.Secrets/Directory.Build.props +++ b/ZB.MOM.WW.Secrets/Directory.Build.props @@ -5,7 +5,7 @@ enable enable latest - 0.5.1 + 0.6.0 true README.md diff --git a/ZB.MOM.WW.Secrets/Directory.Packages.props b/ZB.MOM.WW.Secrets/Directory.Packages.props index 2c9a616..f8c5814 100644 --- a/ZB.MOM.WW.Secrets/Directory.Packages.props +++ b/ZB.MOM.WW.Secrets/Directory.Packages.props @@ -65,6 +65,7 @@ + diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/Configuration/SecretsOptionsValidator.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/Configuration/SecretsOptionsValidator.cs new file mode 100644 index 0000000..c4ac696 --- /dev/null +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/Configuration/SecretsOptionsValidator.cs @@ -0,0 +1,62 @@ +using Microsoft.Extensions.Hosting; +using ZB.MOM.WW.Configuration; + +namespace ZB.MOM.WW.Secrets.Configuration; + +/// +/// Fail-closed startup validation for : the secret store must live at +/// an absolute path outside the application's content root. See +/// for the two incidents that motivate each rule. +/// +/// +/// Registered by +/// with ValidateOnStart, so a bad path fails the boot rather than surfacing later as a +/// missing secret. This is deliberately not conditional on the hosting environment: a guard that is +/// exempted in Development never runs in a dev loop or in CI, so it rots — and the configuration +/// that caused the 2026-08-09 outage looked entirely reasonable, which is exactly the case an +/// unexercised guard fails to catch. +/// +public sealed class SecretsOptionsValidator : OptionsValidatorBase +{ + private readonly string? _contentRootPath; + + /// + /// Initializes a new instance of the class. + /// + /// + /// The host environment supplying the content root, or when the secrets + /// subsystem is composed outside a host (the CLI does this). With no content root the + /// under-content-root rule is skipped; the absolute-path rule still applies. + /// + public SecretsOptionsValidator(IHostEnvironment? hostEnvironment) => + _contentRootPath = hostEnvironment?.ContentRootPath; + + /// + protected override void Validate(ValidationBuilder builder, SecretsOptions options) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(options); + + builder.Required(options.SqlitePath, "Secrets:SqlitePath"); + + SecretsStorePathRules.AddIfNotRooted( + options.SqlitePath, + "Secrets:SqlitePath must be an absolute path. A relative value resolves against the " + + "process working directory — for a Windows service that is C:\\Windows\\System32, " + + "so the store lands outside the installation; in a dev loop it lands in the source " + + "tree. Set an absolute path, or remove the key entirely to accept the " + + "per-user default.", + builder); + + SecretsStorePathRules.AddIfUnderContentRoot( + options.SqlitePath, + _contentRootPath, + $"Secrets:SqlitePath ('{options.SqlitePath}') is inside the application content root " + + $"('{_contentRootPath}'). Deployment procedures in this family replace or rename " + + "that directory on upgrade, which takes the secret store with it and leaves the " + + "process to create a fresh empty one at the same path — an outage that presents as " + + "an authentication failure, not as a deployment error. Put the store in a sibling " + + "directory that the upgrade does not touch.", + builder); + } +} diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/Configuration/SecretsStorePathRules.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/Configuration/SecretsStorePathRules.cs new file mode 100644 index 0000000..58fdaab --- /dev/null +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/Configuration/SecretsStorePathRules.cs @@ -0,0 +1,120 @@ +using ZB.MOM.WW.Configuration; + +namespace ZB.MOM.WW.Secrets.Configuration; + +/// +/// Filesystem-path validation primitives for the secret store's location. +/// +/// +/// +/// Both rules exist because of production incidents, and neither implies the other: +/// +/// +/// +/// +/// Not rooted. A relative path resolves against the process working directory. For a +/// Windows service that is C:\Windows\System32, so the store silently lands outside the +/// installation entirely; in a dev loop it lands in the source tree (observed: a gateway wrote +/// its secrets DB into src/ and tripped the repo's own tree-hygiene test). +/// +/// +/// +/// +/// Under the content root. An absolute path can still point inside the +/// application directory, which every deployment procedure in this family replaces or renames +/// on upgrade. On 2026-08-09 a gateway's API-key store sat at +/// …\MxGateway\Server\gateway-auth.db; the deploy renamed Server to +/// Server.bak.*, the database left with it, and the process created a fresh empty one at +/// the same path. Every consumer lost its credential, silently, for two days. Rooted did not +/// save it — the path was absolute and still doomed. +/// +/// +/// +/// +/// Both reject rather than relocate. Silently moving a credential store is worse than a boot error: +/// a boot error is seen immediately by the person who caused it, whereas a relocated store presents +/// later as an authentication failure pointing nowhere near the configuration that caused it. +/// +/// +public static class SecretsStorePathRules +{ + /// + /// Records a failure when is non-blank and not an absolute (rooted) + /// path on the current OS. Blank is the caller's required-field concern and passes here. + /// + /// The configured path value. + /// The failure message to record. + /// The validation builder accumulating failures. + public static void AddIfNotRooted(string? value, string message, ValidationBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + if (string.IsNullOrWhiteSpace(value)) + { + return; + } + + if (!Path.IsPathRooted(value)) + { + builder.Add(message); + } + } + + /// + /// Records a failure when resolves to a location inside + /// . A blank value or a blank content root passes: there is + /// nothing to compare, and a host that cannot report its content root must not be turned into + /// a boot failure by a rule about a directory it does not know it has. + /// + /// The configured path value. + /// The application content root, or when unknown. + /// The failure message to record. + /// The validation builder accumulating failures. + public static void AddIfUnderContentRoot( + string? value, + string? contentRoot, + string message, + ValidationBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + if (string.IsNullOrWhiteSpace(value) || string.IsNullOrWhiteSpace(contentRoot)) + { + return; + } + + string fullValue; + string fullRoot; + try + { + fullValue = Path.GetFullPath(value); + fullRoot = Path.GetFullPath(contentRoot); + } + catch (Exception ex) when (ex is ArgumentException or NotSupportedException + or PathTooLongException or IOException) + { + // A syntactically invalid path is a different failure with its own message; refusing + // to guess here keeps one rule reporting one thing. + return; + } + + // The separator is load-bearing, not cosmetic. Without it a bare prefix test treats + // "/srv/app-data" as inside "/srv/app" and refuses a legitimate sibling directory — and + // because this rule is fail-closed at startup, that false positive is an application that + // will not boot. + string rootWithSeparator = fullRoot.TrimEnd( + Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar; + + // Path case sensitivity follows the OS, not a convention. Comparing case-insensitively + // everywhere would reject "/srv/App/store.db" as living under "/srv/app" on Linux, where + // those are genuinely different directories. + StringComparison comparison = OperatingSystem.IsWindows() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + + if (fullValue.StartsWith(rootWithSeparator, comparison)) + { + builder.Add(message); + } + } +} diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/DependencyInjection/SecretsServiceCollectionExtensions.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/DependencyInjection/SecretsServiceCollectionExtensions.cs index 3609977..1867b42 100644 --- a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/DependencyInjection/SecretsServiceCollectionExtensions.cs +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/DependencyInjection/SecretsServiceCollectionExtensions.cs @@ -1,9 +1,12 @@ +using System.Linq; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Options; using ZB.MOM.WW.Audit; using ZB.MOM.WW.Secrets.Abstractions; +using ZB.MOM.WW.Secrets.Configuration; using ZB.MOM.WW.Secrets.Crypto; using ZB.MOM.WW.Secrets.MasterKey; using ZB.MOM.WW.Secrets.Sqlite; @@ -36,7 +39,28 @@ public static class SecretsServiceCollectionExtensions ArgumentNullException.ThrowIfNull(config); ArgumentException.ThrowIfNullOrWhiteSpace(sectionPath); - services.Configure(config.GetSection(sectionPath)); + // Bound AND validated, with ValidateOnStart, so a store path that would be destroyed by the + // next deployment fails the boot instead of surfacing later as a missing secret. + // + // The validator must be built by FACTORY, because it takes IHostEnvironment *optionally* — + // constructor injection would make the container throw wherever the secrets subsystem is + // composed without a host, which the CLI legitimately does. + // + // But AddZbSecrets is called more than once in real compositions (a replication package + // calls it internally after the application already has), so registration has to be + // idempotent — and TryAddEnumerable cannot do it here: it de-duplicates on implementation + // TYPE, which a factory descriptor does not carry, so it rejects the second call outright + // rather than skipping it. Guarding on the concrete registration gives both properties. + if (services.All(d => d.ServiceType != typeof(SecretsOptionsValidator))) + { + services.AddSingleton(sp => new SecretsOptionsValidator(sp.GetService())); + services.AddSingleton>( + sp => sp.GetRequiredService()); + } + + services.AddOptions() + .Bind(config.GetSection(sectionPath)) + .ValidateOnStart(); // One connection factory targets the configured SQLite path. Singleton: it is stateless // aside from the path and opens a fresh connection per operation. diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/SecretsOptions.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/SecretsOptions.cs index 5d5ee34..b3cb9ff 100644 --- a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/SecretsOptions.cs +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/SecretsOptions.cs @@ -10,8 +10,48 @@ namespace ZB.MOM.WW.Secrets; /// public sealed class SecretsOptions { - /// The path to the SQLite database backing the secret store. Defaults to secrets.db. - public string SqlitePath { get; set; } = "secrets.db"; + /// + /// The path to the SQLite database backing the secret store. Must be an absolute path outside + /// the application content root — see , which + /// enforces both at startup. Defaults to . + /// + public string SqlitePath { get; set; } = DefaultSqlitePath; + + /// + /// The default store location: ZB.MOM.WW/secrets.db under the per-user local application + /// data folder (~/.local/share on Linux and macOS, %LOCALAPPDATA% on Windows). + /// + /// + /// + /// Per-user rather than machine-wide on purpose. CommonApplicationData is + /// /usr/share on Linux and macOS, which a normal user cannot write — as a library default + /// that would fail the boot of every consuming application on a developer machine until each + /// one set an absolute path. A guard whose first observable effect is breaking every local dev + /// loop is a guard that gets reverted, and then the rule protects nothing. + /// + /// + /// A consuming application is free to compute a different default (a machine-wide Windows + /// service reasonably prefers CommonApplicationData alongside its other stores); the + /// configured value always wins over this one. Production deployments set an explicit absolute + /// path regardless, so this default governs dev loops and unconfigured installs only. + /// + /// + /// When the platform reports no local-application-data folder, this stays relative on purpose. + /// The startup validator then rejects it with a message naming the key, which is a better + /// outcome than inventing a location for a credential store. + /// + /// + public static string DefaultSqlitePath { get; } = ComputeDefaultSqlitePath(); + + private static string ComputeDefaultSqlitePath() + { + string baseDirectory = Environment.GetFolderPath( + Environment.SpecialFolder.LocalApplicationData); + + return string.IsNullOrEmpty(baseDirectory) + ? "secrets.db" + : Path.Combine(baseDirectory, "ZB.MOM.WW", "secrets.db"); + } /// The master-key (KEK) resolution configuration. Defaults to a fresh . public MasterKeyOptions MasterKey { get; set; } = new(); diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/ZB.MOM.WW.Secrets.csproj b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/ZB.MOM.WW.Secrets.csproj index 64c4d5a..5b8f096 100644 --- a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/ZB.MOM.WW.Secrets.csproj +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/ZB.MOM.WW.Secrets.csproj @@ -15,6 +15,7 @@ + diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Configuration/SecretsStorePathRulesTests.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Configuration/SecretsStorePathRulesTests.cs new file mode 100644 index 0000000..8128ad9 --- /dev/null +++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Configuration/SecretsStorePathRulesTests.cs @@ -0,0 +1,246 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; +using ZB.MOM.WW.Configuration; +using ZB.MOM.WW.Secrets.Configuration; +using ZB.MOM.WW.Secrets.DependencyInjection; + +namespace ZB.MOM.WW.Secrets.Tests.Configuration; + +/// +/// Covers the two store-path rules and the validator that applies them. Both rules exist because +/// of production incidents — a relative path landing in the process working directory, and an +/// absolute path landing inside the directory a deployment renames away — so the cases here are +/// written as those failures, not as abstract path arithmetic. +/// +public sealed class SecretsStorePathRulesTests +{ + private static string Root(params string[] segments) => + Path.Combine(OperatingSystem.IsWindows() ? @"C:\zbtest" : "/zbtest", Path.Combine(segments)); + + // ---------- AddIfNotRooted ---------- + + [Fact] + public void NotRooted_rejects_a_relative_path() + { + var builder = new ValidationBuilder(); + SecretsStorePathRules.AddIfNotRooted("secrets.db", "relative", builder); + Assert.Contains("relative", builder.Failures); + } + + [Fact] + public void NotRooted_accepts_an_absolute_path() + { + var builder = new ValidationBuilder(); + SecretsStorePathRules.AddIfNotRooted(Root("data", "secrets.db"), "relative", builder); + Assert.True(builder.IsValid); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void NotRooted_leaves_blank_to_the_required_check(string? value) + { + var builder = new ValidationBuilder(); + SecretsStorePathRules.AddIfNotRooted(value, "relative", builder); + Assert.True(builder.IsValid); + } + + // ---------- AddIfUnderContentRoot ---------- + + [Fact] + public void UnderContentRoot_rejects_a_store_inside_the_application_directory() + { + // The 2026-08-09 shape: absolute, and inside the directory the deploy renames away. + var builder = new ValidationBuilder(); + SecretsStorePathRules.AddIfUnderContentRoot( + Root("app", "Server", "secrets.db"), Root("app", "Server"), "inside", builder); + Assert.Contains("inside", builder.Failures); + } + + [Fact] + public void UnderContentRoot_accepts_a_sibling_directory() + { + // The fix that was actually applied on the box: a sibling data\ directory. + var builder = new ValidationBuilder(); + SecretsStorePathRules.AddIfUnderContentRoot( + Root("app", "data", "secrets.db"), Root("app", "Server"), "inside", builder); + Assert.True(builder.IsValid); + } + + [Fact] + public void UnderContentRoot_does_not_treat_a_name_prefixed_sibling_as_inside() + { + // "/zbtest/app-data" merely STARTS WITH "/zbtest/app". Without a separator before the + // prefix test this is reported as inside the content root, and because the rule is + // fail-closed at startup that false positive is an application that will not boot. + var builder = new ValidationBuilder(); + SecretsStorePathRules.AddIfUnderContentRoot( + Root("app-data", "secrets.db"), Root("app"), "inside", builder); + Assert.True(builder.IsValid); + } + + [Fact] + public void UnderContentRoot_catches_a_path_that_only_escapes_syntactically() + { + // Normalisation matters: this resolves back inside the content root despite the "..". + var builder = new ValidationBuilder(); + SecretsStorePathRules.AddIfUnderContentRoot( + Path.Combine(Root("app", "Server"), "..", "Server", "secrets.db"), + Root("app", "Server"), + "inside", + builder); + Assert.Contains("inside", builder.Failures); + } + + [Fact] + public void UnderContentRoot_skips_when_the_content_root_is_unknown() + { + // Composed outside a host (the CLI does this). A rule about a directory the process does + // not know it has must not become a boot failure. + var builder = new ValidationBuilder(); + SecretsStorePathRules.AddIfUnderContentRoot(Root("anywhere", "secrets.db"), null, "inside", builder); + Assert.True(builder.IsValid); + } + + // ---------- the default ---------- + + [Fact] + public void The_default_store_path_satisfies_the_rules_it_ships_with() + { + // A library whose own default violates its own guard would fail every consumer's first + // boot after upgrading. + var builder = new ValidationBuilder(); + SecretsStorePathRules.AddIfNotRooted(SecretsOptions.DefaultSqlitePath, "relative", builder); + Assert.True(builder.IsValid, $"default was '{SecretsOptions.DefaultSqlitePath}'"); + } + + [Fact] + public void The_default_store_path_is_not_machine_wide() + { + // Deliberately per-user: CommonApplicationData is /usr/share on Linux and macOS, which a + // normal user cannot write, so a machine-wide default would fail every dev boot in the + // family. Asserted rather than commented because it is the whole reason for the choice. + string commonAppData = Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData); + if (!string.IsNullOrEmpty(commonAppData)) + { + Assert.DoesNotContain(commonAppData, SecretsOptions.DefaultSqlitePath, StringComparison.Ordinal); + } + } + + // ---------- the validator ---------- + + private static SecretsOptions OptionsWith(string path) => new() { SqlitePath = path }; + + [Fact] + public void Validator_rejects_a_relative_path_and_names_the_key() + { + var validator = new SecretsOptionsValidator(hostEnvironment: null); + ValidateOptionsResult result = validator.Validate(name: null, OptionsWith("secrets.db")); + + Assert.True(result.Failed); + Assert.Contains(result.Failures!, f => f.Contains("Secrets:SqlitePath", StringComparison.Ordinal)); + } + + [Fact] + public void Validator_rejects_a_store_under_the_content_root() + { + var env = new StubHostEnvironment { ContentRootPath = Root("app", "Server") }; + var validator = new SecretsOptionsValidator(env); + + ValidateOptionsResult result = + validator.Validate(name: null, OptionsWith(Root("app", "Server", "secrets.db"))); + + Assert.True(result.Failed); + Assert.Contains(result.Failures!, f => f.Contains("content root", StringComparison.Ordinal)); + } + + [Fact] + public void Validator_accepts_an_absolute_path_outside_the_content_root() + { + var env = new StubHostEnvironment { ContentRootPath = Root("app", "Server") }; + var validator = new SecretsOptionsValidator(env); + + ValidateOptionsResult result = + validator.Validate(name: null, OptionsWith(Root("app", "data", "secrets.db"))); + + Assert.True(result.Succeeded); + } + + // ---------- registration ---------- + + [Fact] + public void AddZbSecrets_is_idempotent_and_registers_exactly_one_validator() + { + // AddZbSecrets is called twice in real compositions — a replication package calls it after + // the application already has, and one consumer calls it twice directly. An earlier + // implementation used TryAddEnumerable with a factory descriptor, which THROWS on the + // second call ("indistinguishable from other services") rather than skipping. + IConfiguration config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Secrets:SqlitePath"] = Root("app", "data", "secrets.db"), + }) + .Build(); + + var services = new ServiceCollection(); + services.AddZbSecrets(config, "Secrets"); + services.AddZbSecrets(config, "Secrets"); + + Assert.Single(services.Where(d => d.ServiceType == typeof(SecretsOptionsValidator))); + Assert.Single(services.Where(d => d.ServiceType == typeof(IValidateOptions))); + } + + [Fact] + public void A_bad_path_actually_fails_when_the_options_are_resolved() + { + // The rules and the validator can both be correct while the REGISTRATION is wrong, and a + // validator that is never invoked is precisely the "guard that does not fire" failure this + // whole change exists to prevent. So resolve the options for real and require the throw. + IConfiguration config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Secrets:SqlitePath"] = "secrets.db", + }) + .Build(); + + var services = new ServiceCollection(); + services.AddZbSecrets(config, "Secrets"); + using ServiceProvider provider = services.BuildServiceProvider(); + + var ex = Assert.Throws( + () => _ = provider.GetRequiredService>().Value); + Assert.Contains(ex.Failures, f => f.Contains("Secrets:SqlitePath", StringComparison.Ordinal)); + } + + [Fact] + public void A_good_path_resolves_cleanly() + { + // Positive control: without it, the test above would still pass if EVERY path were rejected. + IConfiguration config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Secrets:SqlitePath"] = Root("app", "data", "secrets.db"), + }) + .Build(); + + var services = new ServiceCollection(); + services.AddZbSecrets(config, "Secrets"); + using ServiceProvider provider = services.BuildServiceProvider(); + + Assert.Equal( + Root("app", "data", "secrets.db"), + provider.GetRequiredService>().Value.SqlitePath); + } + + private sealed class StubHostEnvironment : IHostEnvironment + { + public string EnvironmentName { get; set; } = "Production"; + public string ApplicationName { get; set; } = "Test"; + public string ContentRootPath { get; set; } = string.Empty; + public IFileProvider ContentRootFileProvider { get; set; } = null!; + } +}