diff --git a/src/NatsDistributedCache/NatsDistributedCache.csproj b/src/NatsDistributedCache/NatsDistributedCache.csproj
index d6b07b0..9efca0a 100644
--- a/src/NatsDistributedCache/NatsDistributedCache.csproj
+++ b/src/NatsDistributedCache/NatsDistributedCache.csproj
@@ -6,6 +6,11 @@
CodeCargo.Nats.DistributedCache
NATS implementation of IDistributedCache.
+
+ true
+
true
$(MSBuildThisFileDirectory)..\..\keys\NatsDistributedCache.2025-05-12.snk
diff --git a/src/NatsDistributedCache/NatsExtensions.cs b/src/NatsDistributedCache/NatsExtensions.cs
index a0731b4..6a5ac49 100644
--- a/src/NatsDistributedCache/NatsExtensions.cs
+++ b/src/NatsDistributedCache/NatsExtensions.cs
@@ -16,11 +16,10 @@ namespace CodeCargo.Nats.DistributedCache;
/// nats-io/nats.net#852 was backed out. Re-check on future NATS.Net upgrades — once
/// PutAsync/UpdateAsync gain TTL overloads, this class can be deleted in favor of them.
///
-public static class NatsExtensions
+public static partial class NatsExtensions
{
private const string NatsExpectedLastSubjectSequence = "Nats-Expected-Last-Subject-Sequence";
private const string NatsTtl = "Nats-TTL";
- private static readonly Regex ValidKeyRegex = new(pattern: @"\A[-/_=\.a-zA-Z0-9]+\z", RegexOptions.Compiled);
private static readonly NatsKVException KeyCannotBeEmptyException = new("Key cannot be empty");
private static readonly NatsKVException KeyCannotStartOrEndWithPeriodException =
@@ -245,7 +244,7 @@ private static NatsResult TryValidateKey(string key)
return KeyCannotStartOrEndWithPeriodException;
}
- if (!ValidKeyRegex.IsMatch(key))
+ if (!ValidKeyRegex().IsMatch(key))
{
return KeyContainsInvalidCharactersException;
}
@@ -263,4 +262,9 @@ private static string ToTtlString(TimeSpan ttl)
[MethodImpl(MethodImplOptions.NoInlining)]
private static void ThrowException(Exception exception) => throw exception;
+
+ // Source-generated (AOT/trim-safe) replacement for a RegexOptions.Compiled Regex, which relied on
+ // reflection-emit. Pattern is unchanged: \A[-/_=\.a-zA-Z0-9]+\z (\A/\z anchors plus the / = . chars).
+ [GeneratedRegex(@"\A[-/_=\.a-zA-Z0-9]+\z", RegexOptions.Compiled)]
+ private static partial Regex ValidKeyRegex();
}
diff --git a/src/NatsDistributedCache/packages.linux-arm64.lock.json b/src/NatsDistributedCache/packages.linux-arm64.lock.json
index c94be27..cfd83cd 100644
--- a/src/NatsDistributedCache/packages.linux-arm64.lock.json
+++ b/src/NatsDistributedCache/packages.linux-arm64.lock.json
@@ -30,6 +30,12 @@
"Microsoft.Extensions.Primitives": "10.0.9"
}
},
+ "Microsoft.NET.ILLink.Tasks": {
+ "type": "Direct",
+ "requested": "[10.0.10, )",
+ "resolved": "10.0.10",
+ "contentHash": "f5VCIE7AJpd5YvzNTeMGVzQIgyE9tX+AreTYwQF+REbu+DZo/2Ae+jNSwhPEYrVz6RRkd7y8ubXjk6Nn6Ka+Cg=="
+ },
"Microsoft.SourceLink.GitHub": {
"type": "Direct",
"requested": "[10.0.300, )",
@@ -161,6 +167,12 @@
"Microsoft.Extensions.Primitives": "10.0.9"
}
},
+ "Microsoft.NET.ILLink.Tasks": {
+ "type": "Direct",
+ "requested": "[8.0.29, )",
+ "resolved": "8.0.29",
+ "contentHash": "HSBTfrkIZijz8z3ybLRKB7E8rHk4QQufFwpHa9fc5CMIgRhRzdn4mBGmlyXZqaueiMPtuJcnjresGvSTfaW8Mg=="
+ },
"Microsoft.SourceLink.GitHub": {
"type": "Direct",
"requested": "[10.0.300, )",
diff --git a/src/NatsDistributedCache/packages.linux-x64.lock.json b/src/NatsDistributedCache/packages.linux-x64.lock.json
index c94be27..cfd83cd 100644
--- a/src/NatsDistributedCache/packages.linux-x64.lock.json
+++ b/src/NatsDistributedCache/packages.linux-x64.lock.json
@@ -30,6 +30,12 @@
"Microsoft.Extensions.Primitives": "10.0.9"
}
},
+ "Microsoft.NET.ILLink.Tasks": {
+ "type": "Direct",
+ "requested": "[10.0.10, )",
+ "resolved": "10.0.10",
+ "contentHash": "f5VCIE7AJpd5YvzNTeMGVzQIgyE9tX+AreTYwQF+REbu+DZo/2Ae+jNSwhPEYrVz6RRkd7y8ubXjk6Nn6Ka+Cg=="
+ },
"Microsoft.SourceLink.GitHub": {
"type": "Direct",
"requested": "[10.0.300, )",
@@ -161,6 +167,12 @@
"Microsoft.Extensions.Primitives": "10.0.9"
}
},
+ "Microsoft.NET.ILLink.Tasks": {
+ "type": "Direct",
+ "requested": "[8.0.29, )",
+ "resolved": "8.0.29",
+ "contentHash": "HSBTfrkIZijz8z3ybLRKB7E8rHk4QQufFwpHa9fc5CMIgRhRzdn4mBGmlyXZqaueiMPtuJcnjresGvSTfaW8Mg=="
+ },
"Microsoft.SourceLink.GitHub": {
"type": "Direct",
"requested": "[10.0.300, )",
diff --git a/src/NatsDistributedCache/packages.osx-arm64.lock.json b/src/NatsDistributedCache/packages.osx-arm64.lock.json
index c94be27..cfd83cd 100644
--- a/src/NatsDistributedCache/packages.osx-arm64.lock.json
+++ b/src/NatsDistributedCache/packages.osx-arm64.lock.json
@@ -30,6 +30,12 @@
"Microsoft.Extensions.Primitives": "10.0.9"
}
},
+ "Microsoft.NET.ILLink.Tasks": {
+ "type": "Direct",
+ "requested": "[10.0.10, )",
+ "resolved": "10.0.10",
+ "contentHash": "f5VCIE7AJpd5YvzNTeMGVzQIgyE9tX+AreTYwQF+REbu+DZo/2Ae+jNSwhPEYrVz6RRkd7y8ubXjk6Nn6Ka+Cg=="
+ },
"Microsoft.SourceLink.GitHub": {
"type": "Direct",
"requested": "[10.0.300, )",
@@ -161,6 +167,12 @@
"Microsoft.Extensions.Primitives": "10.0.9"
}
},
+ "Microsoft.NET.ILLink.Tasks": {
+ "type": "Direct",
+ "requested": "[8.0.29, )",
+ "resolved": "8.0.29",
+ "contentHash": "HSBTfrkIZijz8z3ybLRKB7E8rHk4QQufFwpHa9fc5CMIgRhRzdn4mBGmlyXZqaueiMPtuJcnjresGvSTfaW8Mg=="
+ },
"Microsoft.SourceLink.GitHub": {
"type": "Direct",
"requested": "[10.0.300, )",
diff --git a/src/NatsDistributedCache/packages.win-x64.lock.json b/src/NatsDistributedCache/packages.win-x64.lock.json
index c94be27..cfd83cd 100644
--- a/src/NatsDistributedCache/packages.win-x64.lock.json
+++ b/src/NatsDistributedCache/packages.win-x64.lock.json
@@ -30,6 +30,12 @@
"Microsoft.Extensions.Primitives": "10.0.9"
}
},
+ "Microsoft.NET.ILLink.Tasks": {
+ "type": "Direct",
+ "requested": "[10.0.10, )",
+ "resolved": "10.0.10",
+ "contentHash": "f5VCIE7AJpd5YvzNTeMGVzQIgyE9tX+AreTYwQF+REbu+DZo/2Ae+jNSwhPEYrVz6RRkd7y8ubXjk6Nn6Ka+Cg=="
+ },
"Microsoft.SourceLink.GitHub": {
"type": "Direct",
"requested": "[10.0.300, )",
@@ -161,6 +167,12 @@
"Microsoft.Extensions.Primitives": "10.0.9"
}
},
+ "Microsoft.NET.ILLink.Tasks": {
+ "type": "Direct",
+ "requested": "[8.0.29, )",
+ "resolved": "8.0.29",
+ "contentHash": "HSBTfrkIZijz8z3ybLRKB7E8rHk4QQufFwpHa9fc5CMIgRhRzdn4mBGmlyXZqaueiMPtuJcnjresGvSTfaW8Mg=="
+ },
"Microsoft.SourceLink.GitHub": {
"type": "Direct",
"requested": "[10.0.300, )",
diff --git a/src/NatsHybridCacheExtensions/NatsHybridCacheExtensions.csproj b/src/NatsHybridCacheExtensions/NatsHybridCacheExtensions.csproj
index 989e792..19160c1 100644
--- a/src/NatsHybridCacheExtensions/NatsHybridCacheExtensions.csproj
+++ b/src/NatsHybridCacheExtensions/NatsHybridCacheExtensions.csproj
@@ -6,6 +6,11 @@
CodeCargo.Nats.HybridCacheExtensions
Extensions for using HybridCache with NATS.
+
+ true
+
true
$(MSBuildThisFileDirectory)..\..\keys\NatsDistributedCache.2025-05-12.snk
diff --git a/src/NatsHybridCacheExtensions/packages.linux-arm64.lock.json b/src/NatsHybridCacheExtensions/packages.linux-arm64.lock.json
index 597e28b..c978076 100644
--- a/src/NatsHybridCacheExtensions/packages.linux-arm64.lock.json
+++ b/src/NatsHybridCacheExtensions/packages.linux-arm64.lock.json
@@ -14,6 +14,12 @@
"Microsoft.Extensions.Options": "10.0.9"
}
},
+ "Microsoft.NET.ILLink.Tasks": {
+ "type": "Direct",
+ "requested": "[10.0.10, )",
+ "resolved": "10.0.10",
+ "contentHash": "f5VCIE7AJpd5YvzNTeMGVzQIgyE9tX+AreTYwQF+REbu+DZo/2Ae+jNSwhPEYrVz6RRkd7y8ubXjk6Nn6Ka+Cg=="
+ },
"Microsoft.SourceLink.GitHub": {
"type": "Direct",
"requested": "[10.0.300, )",
@@ -167,6 +173,12 @@
"Microsoft.Extensions.Options": "10.0.9"
}
},
+ "Microsoft.NET.ILLink.Tasks": {
+ "type": "Direct",
+ "requested": "[8.0.29, )",
+ "resolved": "8.0.29",
+ "contentHash": "HSBTfrkIZijz8z3ybLRKB7E8rHk4QQufFwpHa9fc5CMIgRhRzdn4mBGmlyXZqaueiMPtuJcnjresGvSTfaW8Mg=="
+ },
"Microsoft.SourceLink.GitHub": {
"type": "Direct",
"requested": "[10.0.300, )",
diff --git a/src/NatsHybridCacheExtensions/packages.linux-x64.lock.json b/src/NatsHybridCacheExtensions/packages.linux-x64.lock.json
index 597e28b..c978076 100644
--- a/src/NatsHybridCacheExtensions/packages.linux-x64.lock.json
+++ b/src/NatsHybridCacheExtensions/packages.linux-x64.lock.json
@@ -14,6 +14,12 @@
"Microsoft.Extensions.Options": "10.0.9"
}
},
+ "Microsoft.NET.ILLink.Tasks": {
+ "type": "Direct",
+ "requested": "[10.0.10, )",
+ "resolved": "10.0.10",
+ "contentHash": "f5VCIE7AJpd5YvzNTeMGVzQIgyE9tX+AreTYwQF+REbu+DZo/2Ae+jNSwhPEYrVz6RRkd7y8ubXjk6Nn6Ka+Cg=="
+ },
"Microsoft.SourceLink.GitHub": {
"type": "Direct",
"requested": "[10.0.300, )",
@@ -167,6 +173,12 @@
"Microsoft.Extensions.Options": "10.0.9"
}
},
+ "Microsoft.NET.ILLink.Tasks": {
+ "type": "Direct",
+ "requested": "[8.0.29, )",
+ "resolved": "8.0.29",
+ "contentHash": "HSBTfrkIZijz8z3ybLRKB7E8rHk4QQufFwpHa9fc5CMIgRhRzdn4mBGmlyXZqaueiMPtuJcnjresGvSTfaW8Mg=="
+ },
"Microsoft.SourceLink.GitHub": {
"type": "Direct",
"requested": "[10.0.300, )",
diff --git a/src/NatsHybridCacheExtensions/packages.osx-arm64.lock.json b/src/NatsHybridCacheExtensions/packages.osx-arm64.lock.json
index 597e28b..c978076 100644
--- a/src/NatsHybridCacheExtensions/packages.osx-arm64.lock.json
+++ b/src/NatsHybridCacheExtensions/packages.osx-arm64.lock.json
@@ -14,6 +14,12 @@
"Microsoft.Extensions.Options": "10.0.9"
}
},
+ "Microsoft.NET.ILLink.Tasks": {
+ "type": "Direct",
+ "requested": "[10.0.10, )",
+ "resolved": "10.0.10",
+ "contentHash": "f5VCIE7AJpd5YvzNTeMGVzQIgyE9tX+AreTYwQF+REbu+DZo/2Ae+jNSwhPEYrVz6RRkd7y8ubXjk6Nn6Ka+Cg=="
+ },
"Microsoft.SourceLink.GitHub": {
"type": "Direct",
"requested": "[10.0.300, )",
@@ -167,6 +173,12 @@
"Microsoft.Extensions.Options": "10.0.9"
}
},
+ "Microsoft.NET.ILLink.Tasks": {
+ "type": "Direct",
+ "requested": "[8.0.29, )",
+ "resolved": "8.0.29",
+ "contentHash": "HSBTfrkIZijz8z3ybLRKB7E8rHk4QQufFwpHa9fc5CMIgRhRzdn4mBGmlyXZqaueiMPtuJcnjresGvSTfaW8Mg=="
+ },
"Microsoft.SourceLink.GitHub": {
"type": "Direct",
"requested": "[10.0.300, )",
diff --git a/src/NatsHybridCacheExtensions/packages.win-x64.lock.json b/src/NatsHybridCacheExtensions/packages.win-x64.lock.json
index 597e28b..c978076 100644
--- a/src/NatsHybridCacheExtensions/packages.win-x64.lock.json
+++ b/src/NatsHybridCacheExtensions/packages.win-x64.lock.json
@@ -14,6 +14,12 @@
"Microsoft.Extensions.Options": "10.0.9"
}
},
+ "Microsoft.NET.ILLink.Tasks": {
+ "type": "Direct",
+ "requested": "[10.0.10, )",
+ "resolved": "10.0.10",
+ "contentHash": "f5VCIE7AJpd5YvzNTeMGVzQIgyE9tX+AreTYwQF+REbu+DZo/2Ae+jNSwhPEYrVz6RRkd7y8ubXjk6Nn6Ka+Cg=="
+ },
"Microsoft.SourceLink.GitHub": {
"type": "Direct",
"requested": "[10.0.300, )",
@@ -167,6 +173,12 @@
"Microsoft.Extensions.Options": "10.0.9"
}
},
+ "Microsoft.NET.ILLink.Tasks": {
+ "type": "Direct",
+ "requested": "[8.0.29, )",
+ "resolved": "8.0.29",
+ "contentHash": "HSBTfrkIZijz8z3ybLRKB7E8rHk4QQufFwpHa9fc5CMIgRhRzdn4mBGmlyXZqaueiMPtuJcnjresGvSTfaW8Mg=="
+ },
"Microsoft.SourceLink.GitHub": {
"type": "Direct",
"requested": "[10.0.300, )",
diff --git a/test/UnitTests/Extensions/NatsExtensionsKeyValidationTests.cs b/test/UnitTests/Extensions/NatsExtensionsKeyValidationTests.cs
new file mode 100644
index 0000000..0167427
--- /dev/null
+++ b/test/UnitTests/Extensions/NatsExtensionsKeyValidationTests.cs
@@ -0,0 +1,59 @@
+using Moq;
+using NATS.Client.KeyValueStore;
+
+namespace CodeCargo.Nats.DistributedCache.UnitTests.Extensions;
+
+///
+/// Verifies the source-generated [GeneratedRegex] key validator in
+/// accepts and rejects exactly the same keys as the previous RegexOptions.Compiled implementation.
+/// TryValidateKey is private, so validation is exercised through the public TryPutWithTtlAsync entry
+/// point: a valid key passes validation and reaches the store, while an invalid key short-circuits
+/// with a NatsKVException before the store is touched.
+///
+public class NatsExtensionsKeyValidationTests
+{
+ [Theory]
+ [InlineData("abc123")]
+ [InlineData("a-b_c.d/e=f")] // full character set: - _ . / = plus letters and digits
+ [InlineData("UPPER-lower-0-9")]
+ [InlineData("path/to/key")]
+ [InlineData("has.dots.inside")] // internal dots are allowed
+ public async Task TryPutWithTtlAsync_ValidKey_PassesValidationAndReachesStore(string key)
+ {
+ // A valid key must pass validation and proceed to the store. The mocked store throws a
+ // sentinel the moment its JetStreamContext is read (the first store member the extension
+ // touches), so observing that exact sentinel proves validation accepted the key.
+ var sentinel = new InvalidOperationException("reached-store");
+ var store = new Mock();
+ store.SetupGet(s => s.JetStreamContext).Throws(sentinel);
+
+ var thrown = await Assert.ThrowsAsync(
+ async () => await store.Object.TryPutWithTtlAsync(key, "value"));
+
+ Assert.Same(sentinel, thrown);
+ }
+
+ [Theory]
+ [InlineData("", "Key cannot be empty")]
+ [InlineData(" ", "Key cannot be empty")]
+ [InlineData(".lead", "Key cannot start or end with a period")]
+ [InlineData("trail.", "Key cannot start or end with a period")]
+ [InlineData(".", "Key cannot start or end with a period")]
+ [InlineData("has space", "Key contains invalid characters")]
+ [InlineData("bad!char", "Key contains invalid characters")]
+ [InlineData("with@sign", "Key contains invalid characters")]
+ public async Task TryPutWithTtlAsync_InvalidKey_ReturnsErrorWithoutTouchingStore(
+ string key,
+ string expectedMessage)
+ {
+ // MockBehavior.Strict fails the test on any store access, proving invalid keys are rejected
+ // before the extension touches the store.
+ var store = new Mock(MockBehavior.Strict);
+
+ var result = await store.Object.TryPutWithTtlAsync(key, "value");
+
+ Assert.False(result.Success);
+ Assert.IsType(result.Error);
+ Assert.Equal(expectedMessage, result.Error.Message);
+ }
+}