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);
}