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!;
+ }
+}