fix(secrets): close the pre-host hole in the store-path guard (0.6.1)

Found by the HistorianGateway adoption of 0.6.0, which probed the rejected paths
instead of observing a clean boot — a clean boot being equally consistent with the
validator not being wired at all.

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 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 and
-shm siblings, at the rejected path — the same artifact that made the MxGateway outage
read as "the database is there, it's just empty", which is precisely what this guard
exists to prevent. A relative path was unaffected because the absolute-path rule fires
with or without a content root; that asymmetry is what exposed it.

The mistake was conflating "no IHostEnvironment registered" with "no content root
exists". New AddZbSecrets overload takes the content root explicitly; the 3-argument
overload is unchanged and still falls back to IHostEnvironment, so this is additive.
SecretsOptionsValidator gains a string? constructor alongside the IHostEnvironment? one.

Reproduced first in an isolated harness before changing anything: a bare host rejects
both bad paths leaving no artifacts, and a bare ServiceCollection with no
IHostEnvironment migrates an in-content-root path to disk. Both are now regression tests,
the second paired with a positive control asserting the old behaviour still holds when no
content root is supplied — so the test is about the explicit content root rather than
about paths in general.

Also documents an upgrade note that applies to 0.6.0: a store seeded under the old
relative default is orphaned rather than migrated, and nothing signals it.
This commit is contained in:
Joseph Doherty
2026-08-11 08:37:53 -04:00
parent 105c230cb1
commit cbfe296279
5 changed files with 170 additions and 7 deletions
@@ -25,11 +25,35 @@ public sealed class SecretsOptionsValidator : OptionsValidatorBase<SecretsOption
/// </summary>
/// <param name="hostEnvironment">
/// The host environment supplying the content root, or <see langword="null"/> 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.
/// </param>
public SecretsOptionsValidator(IHostEnvironment? hostEnvironment) =>
_contentRootPath = hostEnvironment?.ContentRootPath;
public SecretsOptionsValidator(IHostEnvironment? hostEnvironment)
: this(hostEnvironment?.ContentRootPath)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="SecretsOptionsValidator"/> class with an
/// explicit content root.
/// </summary>
/// <param name="contentRootPath">
/// The application content root, or <see langword="null"/> when there genuinely is none.
/// </param>
/// <remarks>
/// This overload exists because "no <see cref="IHostEnvironment"/> registered" and "no content
/// root exists" are different things, and conflating them left a hole in the guard. An
/// application that expands <c>${secret:}</c> references BEFORE building its host composes the
/// subsystem in a throwaway <c>ServiceCollection</c> with no <see cref="IHostEnvironment"/> 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 <see cref="IHostEnvironment"/> 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.
/// </remarks>
public SecretsOptionsValidator(string? contentRootPath) =>
_contentRootPath = contentRootPath;
/// <inheritdoc />
protected override void Validate(ValidationBuilder builder, SecretsOptions options)
@@ -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);
/// <summary>
/// As <see cref="AddZbSecrets(IServiceCollection, IConfiguration, string)"/>, with the content
/// root supplied explicitly rather than resolved from <see cref="IHostEnvironment"/>.
/// </summary>
/// <param name="services">The service collection to add to.</param>
/// <param name="config">The application configuration.</param>
/// <param name="sectionPath">Path of the configuration section holding the secrets options.</param>
/// <param name="contentRootPath">
/// The application content root. Pass this whenever the subsystem is composed OUTSIDE a host —
/// most importantly from a pre-host <c>${secret:}</c> expander, which builds a throwaway
/// container with no <see cref="IHostEnvironment"/> 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 <see langword="null"/> only where there genuinely is no content root, such as the CLI.
/// </param>
/// <returns>The same <paramref name="services"/> instance, for chaining.</returns>
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<IHostEnvironment>()));
// 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<IHostEnvironment>()?.ContentRootPath));
services.AddSingleton<IValidateOptions<SecretsOptions>>(
sp => sp.GetRequiredService<SecretsOptionsValidator>());
}