diff --git a/ZB.MOM.WW.Secrets/Directory.Build.props b/ZB.MOM.WW.Secrets/Directory.Build.props index 456ba04..974d6cf 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.1.1 + 0.1.2 true README.md diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/Configuration/SecretReferenceExpander.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/Configuration/SecretReferenceExpander.cs index 2edf0ac..acecf37 100644 --- a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/Configuration/SecretReferenceExpander.cs +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/Configuration/SecretReferenceExpander.cs @@ -83,6 +83,13 @@ public sealed class SecretReferenceExpander /// or ValidateOnStart executes; otherwise validators may see the unexpanded placeholder. /// Fail-closed: a missing referenced secret propagates out /// of startup. + /// + /// Documentation/comment keys are skipped: any entry whose leaf key segment (the part after the + /// last :) begins with an underscore — the widely-used "_comment" JSON convention — + /// is left untouched. This lets an appsettings file document the ${secret:...} syntax in a + /// _comment value (even with a literal example token) without that example being treated as + /// a real reference to resolve. Functional keys are never named with a leading underscore. + /// /// /// The loaded configuration root to mutate. /// A token to cancel the operation. @@ -95,7 +102,9 @@ public sealed class SecretReferenceExpander var pending = new List>(); foreach (KeyValuePair entry in config.AsEnumerable()) { - if (entry.Value is { } value && value.Contains(TokenMarker, StringComparison.Ordinal)) + if (entry.Value is { } value + && value.Contains(TokenMarker, StringComparison.Ordinal) + && !IsCommentKey(entry.Key)) { pending.Add(new KeyValuePair(entry.Key, value)); } @@ -106,4 +115,14 @@ public sealed class SecretReferenceExpander config[entry.Key] = await ExpandAsync(entry.Value, ct).ConfigureAwait(false); } } + + // A configuration key is a comment/documentation key when its leaf segment (after the last ':') + // starts with '_' — the conventional "_comment" marker. Such values may legitimately contain a + // literal ${secret:...} example and must not be resolved. + private static bool IsCommentKey(string key) + { + int lastSeparator = key.LastIndexOf(':'); + string leaf = lastSeparator >= 0 ? key[(lastSeparator + 1)..] : key; + return leaf.StartsWith('_'); + } } diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Configuration/SecretReferenceExpanderTests.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Configuration/SecretReferenceExpanderTests.cs index dd868f8..bcc2eb8 100644 --- a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Configuration/SecretReferenceExpanderTests.cs +++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Configuration/SecretReferenceExpanderTests.cs @@ -103,4 +103,30 @@ public sealed class SecretReferenceExpanderTests await Assert.ThrowsAsync( () => expander.ExpandConfigurationAsync(config, CancellationToken.None)); } + + [Fact] + public async Task ExpandConfigurationAsync_SkipsCommentKeys() + { + // A "_comment" key may document the ${secret:...} syntax with a literal (even malformed or + // not-yet-seeded) example token; it must be left untouched — never resolved, never throwing — + // while a real sibling key still expands. Regression: an unseeded/ellipsis example in a comment + // previously crashed boot (SecretName rejects '...'). + SecretReferenceExpander expander = Expander(("sql/foo", "hunter2")); + IConfigurationRoot config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["_secretsComment"] = "e.g. Password=${secret:...} or ${secret:sql/not-seeded}", + ["Nested:_comment"] = "also skipped: ${secret:sql/also-absent}", + ["ConnectionStrings:Db"] = "Server=.;Password=${secret:sql/foo}", + }) + .Build(); + + await expander.ExpandConfigurationAsync(config, CancellationToken.None); + + // Comment keys untouched (literal token preserved, no throw despite absent/malformed names). + Assert.Equal("e.g. Password=${secret:...} or ${secret:sql/not-seeded}", config["_secretsComment"]); + Assert.Contains("${secret:sql/also-absent}", config["Nested:_comment"]); + // Real key still expanded. + Assert.Equal("Server=.;Password=hunter2", config["ConnectionStrings:Db"]); + } }