diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Abstractions/SecretName.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Abstractions/SecretName.cs index 3eef4f7..7a8a0b4 100644 --- a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Abstractions/SecretName.cs +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Abstractions/SecretName.cs @@ -16,8 +16,9 @@ public readonly record struct SecretName /// The raw name to normalize and validate. /// /// The value is null, empty, or whitespace; contains the path-traversal sequence - /// ..; or contains any character outside [a-z0-9._/-] (after trim and - /// lowercasing). + /// ..; is rooted (starts or ends with /) or contains an empty path + /// segment (//); or contains any character outside [a-z0-9._/-] (after + /// trim and lowercasing). /// public SecretName(string value) { @@ -37,6 +38,14 @@ public readonly record struct SecretName nameof(value)); } + if (normalized.StartsWith('/') || normalized.EndsWith('/') || + normalized.Contains("//", StringComparison.Ordinal)) + { + throw new ArgumentException( + $"Secret name must not be rooted or contain an empty path segment: it may not start or end with '/' or contain '//' (was '{value}').", + nameof(value)); + } + foreach (char c in normalized) { if (!AllowedChars.Contains(c, StringComparison.Ordinal)) @@ -47,17 +56,22 @@ public readonly record struct SecretName } } - Value = normalized; + _value = normalized; } + private readonly string? _value; + /// The normalized secret name (trimmed, lowercased, allow-list validated). - public string Value { get; } + /// The instance is the uninitialized default struct value. + public string Value => _value ?? throw new InvalidOperationException("SecretName is uninitialized (default struct value)."); /// Returns the normalized . /// The normalized secret name. + /// The instance is the uninitialized default struct value. public override string ToString() => Value; /// Implicitly converts a to its underlying string value. /// The secret name to convert. + /// The instance is the uninitialized default struct value. public static implicit operator string(SecretName name) => name.Value; } diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/SecretNameTests.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/SecretNameTests.cs index 964f783..ff9c8c0 100644 --- a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/SecretNameTests.cs +++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/SecretNameTests.cs @@ -18,4 +18,22 @@ public class SecretNameTests [InlineData("../escape")] public void RejectsInvalid(string input) => Assert.Throws(() => new SecretName(input)); + + [Theory] + [InlineData("/etc/passwd")] // rooted / absolute + [InlineData("/")] // rooted (starts and ends with '/') + [InlineData("sql//foo")] // empty path segment + [InlineData("sql/foo/")] // trailing '/' + public void RejectsRootedOrEmptySegment(string input) + => Assert.Throws(() => new SecretName(input)); + + [Fact] + public void DefaultInstance_ThrowsOnValue() + => Assert.Throws(() => _ = default(SecretName).Value); + + [Fact] + public void ValidNestedName_RoundTrips() + => Assert.Equal( + "sql/historiangw/historian-password", + new SecretName("sql/historiangw/historian-password").Value); }