diff --git a/ZB.MOM.WW.Secrets/CHANGELOG.md b/ZB.MOM.WW.Secrets/CHANGELOG.md index 2fd5711..ac38ec7 100644 --- a/ZB.MOM.WW.Secrets/CHANGELOG.md +++ b/ZB.MOM.WW.Secrets/CHANGELOG.md @@ -5,6 +5,44 @@ 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.1 — 2026-08-11 + +### Fixed + +- **The 0.6.0 guard had a hole at exactly the moment the store file is created** (found by the + HistorianGateway adoption of 0.6.0, which probed the rejected paths rather than observing a + clean boot). An application that expands `${secret:}` references *before* building its host + composes this subsystem in a throwaway `ServiceCollection` with **no `IHostEnvironment` in it**, + then migrates the store from that container. `SecretsOptionsValidator` resolves the content root + from `IHostEnvironment` and skips the under-content-root rule when it is absent — so pre-host an + absolute in-content-root path passed the only rule still applying, the migrator **created the + database**, and the boot then failed a second later once the real host registered + `IHostEnvironment` and the rule finally fired. + + The observable result was a rejected boot that *still left an empty store, with `-wal`/`-shm` + siblings, sitting at the rejected path* — the same artifact that made the outage this guard + exists to prevent read as "the database is there, it's just empty". A relative path was + unaffected, because the absolute-path rule fires with or without a content root; that asymmetry + is what exposed it. + + Conflating "no `IHostEnvironment` registered" with "no content root exists" was the mistake. New + overload **`AddZbSecrets(services, config, sectionPath, contentRootPath)`** takes the content + root explicitly; the 3-argument overload is unchanged and still falls back to `IHostEnvironment`. + **Pre-host composition should pass it** — e.g. `builder.Environment.ContentRootPath`. Passing + `null` remains correct only where there genuinely is no content root, such as the CLI. + `SecretsOptionsValidator` gains a `string?` constructor alongside the `IHostEnvironment?` one. + +### Upgrade note (applies to 0.6.0 as well) + +- **A store seeded under the pre-0.6.0 default is orphaned, not migrated, and nothing says so.** + The old default was the relative `secrets.db`, which resolved against the process working + directory; the new default is absolute. An application that relied on the old default silently + starts using a different file, and anything previously seeded into the old one is stranded. This + is a dev-loop concern for the apps in this family (every deployment sets an explicit absolute + path), but check for a stray `*-secrets.db` beside your project before assuming an empty store + after upgrade means the store was lost. Deleting the key from configuration, rather than leaving + a relative value, is what makes the new default apply. + ## 0.6.0 — 2026-08-11 ### Added diff --git a/ZB.MOM.WW.Secrets/Directory.Build.props b/ZB.MOM.WW.Secrets/Directory.Build.props index 500b2c2..67d33d2 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.6.0 + 0.6.1 true README.md 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 index c4ac696..2027495 100644 --- 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 @@ -25,11 +25,35 @@ public sealed class SecretsOptionsValidator : OptionsValidatorBase /// /// 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. + /// subsystem is composed outside a host. With no content root the under-content-root rule is + /// skipped; the absolute-path rule still applies. /// - public SecretsOptionsValidator(IHostEnvironment? hostEnvironment) => - _contentRootPath = hostEnvironment?.ContentRootPath; + public SecretsOptionsValidator(IHostEnvironment? hostEnvironment) + : this(hostEnvironment?.ContentRootPath) + { + } + + /// + /// Initializes a new instance of the class with an + /// explicit content root. + /// + /// + /// The application content root, or when there genuinely is none. + /// + /// + /// This overload exists because "no registered" and "no content + /// root exists" are different things, and conflating them left a hole in the guard. An + /// application that expands ${secret:} references BEFORE building its host composes the + /// subsystem in a throwaway ServiceCollection with no in + /// it, then migrates the store from that container — so the content-root rule was skipped at + /// exactly the moment the store file gets created. The boot then failed a second later, once + /// the real host registered and the rule finally fired, leaving + /// an empty database sitting at the rejected path: the same artifact that made the outage this + /// guard exists to prevent read as "the database is there, it's just empty". + /// Pre-host composition should pass the content root here explicitly. + /// + public SecretsOptionsValidator(string? contentRootPath) => + _contentRootPath = contentRootPath; /// protected override void Validate(ValidationBuilder builder, SecretsOptions options) 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 1867b42..11363da 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 @@ -33,7 +33,29 @@ public static class SecretsServiceCollectionExtensions public static IServiceCollection AddZbSecrets( this IServiceCollection services, IConfiguration config, - string sectionPath) + string sectionPath) => services.AddZbSecrets(config, sectionPath, contentRootPath: null); + + /// + /// As , with the content + /// root supplied explicitly rather than resolved from . + /// + /// The service collection to add to. + /// The application configuration. + /// Path of the configuration section holding the secrets options. + /// + /// The application content root. Pass this whenever the subsystem is composed OUTSIDE a host — + /// most importantly from a pre-host ${secret:} expander, which builds a throwaway + /// container with no in it and then migrates the store. Without + /// it the under-content-root rule is skipped at exactly the moment the store file is created, + /// so a rejected path still gets an empty database written to it before the real host aborts. + /// Pass only where there genuinely is no content root, such as the CLI. + /// + /// The same instance, for chaining. + public static IServiceCollection AddZbSecrets( + this IServiceCollection services, + IConfiguration config, + string sectionPath, + string? contentRootPath) { ArgumentNullException.ThrowIfNull(services); ArgumentNullException.ThrowIfNull(config); @@ -53,7 +75,10 @@ public static class SecretsServiceCollectionExtensions // 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())); + // An explicitly supplied content root wins; otherwise fall back to IHostEnvironment, + // which is present in a real host and absent in a pre-host container. + services.AddSingleton(sp => new SecretsOptionsValidator( + contentRootPath ?? sp.GetService()?.ContentRootPath)); services.AddSingleton>( sp => sp.GetRequiredService()); } 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 index 8128ad9..f17dd3e 100644 --- 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 @@ -6,6 +6,7 @@ using Microsoft.Extensions.Options; using ZB.MOM.WW.Configuration; using ZB.MOM.WW.Secrets.Configuration; using ZB.MOM.WW.Secrets.DependencyInjection; +using ZB.MOM.WW.Secrets.Sqlite; namespace ZB.MOM.WW.Secrets.Tests.Configuration; @@ -236,6 +237,81 @@ public sealed class SecretsStorePathRulesTests provider.GetRequiredService>().Value.SqlitePath); } + [Fact] + public void A_pre_host_container_given_the_content_root_refuses_before_the_store_is_created() + { + // REGRESSION (found by the HistorianGateway adoption of 0.6.0). An application that expands + // ${secret:} references before building its host composes this subsystem in a throwaway + // ServiceCollection with NO IHostEnvironment, then migrates the store from that container. + // The content-root rule was skipped there — so an absolute in-content-root path passed the + // only rule still applying, the migrator CREATED the database, and the boot then failed a + // second later once the real host registered IHostEnvironment. That left an empty store at + // the rejected path: the exact artifact that made the outage this guard exists to prevent + // read as "the database is there, it's just empty". + string contentRoot = Path.Combine(Path.GetTempPath(), $"zb-prehost-{Guid.NewGuid():N}"); + Directory.CreateDirectory(contentRoot); + string dbPath = Path.Combine(contentRoot, "store.db"); + + try + { + IConfiguration config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Secrets:SqlitePath"] = dbPath, + }) + .Build(); + + var services = new ServiceCollection(); + services.AddZbSecrets(config, "Secrets", contentRoot); // the content root, explicitly + using ServiceProvider provider = services.BuildServiceProvider(); + + Assert.Throws( + () => _ = provider.GetRequiredService()); + + // The point of the test: refused BEFORE anything touched the filesystem. + Assert.False(File.Exists(dbPath), "a rejected path must not be left holding a store file"); + Assert.False(File.Exists(dbPath + "-wal")); + Assert.False(File.Exists(dbPath + "-shm")); + } + finally + { + Directory.Delete(contentRoot, recursive: true); + } + } + + [Fact] + public void Without_a_content_root_the_pre_host_container_still_cannot_be_told_it_is_fine() + { + // Positive control for the test above: the SAME path, with the content root NOT supplied, + // is the pre-0.6.1 behaviour — the rule is skipped and nothing objects. This is what makes + // the assertion above about the explicit content root rather than about paths in general. + string contentRoot = Path.Combine(Path.GetTempPath(), $"zb-prehost-{Guid.NewGuid():N}"); + Directory.CreateDirectory(contentRoot); + string dbPath = Path.Combine(contentRoot, "store.db"); + + try + { + IConfiguration config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Secrets:SqlitePath"] = dbPath, + }) + .Build(); + + var services = new ServiceCollection(); + services.AddZbSecrets(config, "Secrets"); // no content root — the CLI's situation + using ServiceProvider provider = services.BuildServiceProvider(); + + // Absolute, so the rooted rule passes; the content-root rule cannot fire. Documented, + // and the reason pre-host composition must pass the content root explicitly. + Assert.NotNull(provider.GetRequiredService()); + } + finally + { + Directory.Delete(contentRoot, recursive: true); + } + } + private sealed class StubHostEnvironment : IHostEnvironment { public string EnvironmentName { get; set; } = "Production";