From 163c818133648c21168c703ca7cc26c2ff7b6f5e Mon Sep 17 00:00:00 2001 From: Ziya Suzen Date: Thu, 25 Jun 2026 13:14:02 +0100 Subject: [PATCH 01/12] objectstore: simplify base64url encoder Replace the hand-rolled bit-twiddling encode loop with the in-box Base64.EncodeToUtf8 (vectorized, available on every target framework via System.Memory) plus an in-place '+'/'/' -> '-'/'_' swap. The '=' padding is kept so output stays byte-for-byte compatible with the padded base64.URLEncoding other clients use for digests and metadata subject names. On read, compare the digest as spans instead of building a "SHA-256=" interpolated string per object. Co-authored-by: to11mtm <12536917+to11mtm@users.noreply.github.com> --- .../Internal/Encoder.cs | 128 ++++++++---------- src/NATS.Client.ObjectStore/NatsObjStore.cs | 9 +- 2 files changed, 61 insertions(+), 76 deletions(-) diff --git a/src/NATS.Client.ObjectStore/Internal/Encoder.cs b/src/NATS.Client.ObjectStore/Internal/Encoder.cs index 07aaadc1f..d2e6f802a 100644 --- a/src/NATS.Client.ObjectStore/Internal/Encoder.cs +++ b/src/NATS.Client.ObjectStore/Internal/Encoder.cs @@ -18,15 +18,6 @@ internal static class Base64UrlEncoder private const char Base64UrlCharacter62 = '-'; private const char Base64UrlCharacter63 = '_'; - /// - /// Encoding table - /// - private static readonly char[] SBase64Table = new[] - { - 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', '0', '1', '2', '3', '4', - '5', '6', '7', '8', '9', Base64UrlCharacter62, Base64UrlCharacter63, - }; - public static string Sha256(ReadOnlySpan value) { Span destination = stackalloc byte[256 / 8]; @@ -59,83 +50,49 @@ public static string Encode(string arg) } /// - /// Converts a subset of an array of 8-bit unsigned integers to its equivalent string representation which is encoded with base-64-url digits. Parameters specify - /// the subset as an offset in the input array, and the number of elements in the array to convert. + /// Converts the bytes to their equivalent base64url string representation. /// - /// An array of 8-bit unsigned integers. - /// Remove padding - /// The string representation in base 64 url encoding of length elements of inArray, starting at position offset. - /// 'inArray' is null. - /// offset or length is negative OR offset plus length is greater than the length of inArray. + /// The bytes to encode. + /// Remove padding. + /// The base64url encoding of the bytes. public static string Encode(Span inArray, bool raw = false) { - var offset = 0; var length = inArray.Length; - if (length == 0) return string.Empty; - var lengthMod3 = length % 3; - var limit = length - lengthMod3; - var output = new char[(length + 2) / 3 * 4]; - var table = SBase64Table; - int i, j = 0; - - // takes 3 bytes from inArray and insert 4 bytes into output - for (i = offset; i < limit; i += 3) + // Base64.EncodeToUtf8 reads the input span directly (no array copy) and the output is + // ASCII, so the url-safe swap runs on the bytes and the string is built from them. + var base64Length = (length + 2) / 3 * 4; +#if NETSTANDARD2_0 + var rented = ArrayPool.Shared.Rent(base64Length); + try { - var d0 = inArray[i]; - var d1 = inArray[i + 1]; - var d2 = inArray[i + 2]; - - output[j + 0] = table[d0 >> 2]; - output[j + 1] = table[((d0 & 0x03) << 4) | (d1 >> 4)]; - output[j + 2] = table[((d1 & 0x0f) << 2) | (d2 >> 6)]; - output[j + 3] = table[d2 & 0x3f]; - j += 4; + _ = System.Buffers.Text.Base64.EncodeToUtf8(inArray, rented, out _, out var written); + var count = MakeUrlSafe(rented.AsSpan(0, written), raw); + return Encoding.ASCII.GetString(rented, 0, count); } - - // Where we left off before - i = limit; - - switch (lengthMod3) + finally { - case 2: - { - var d0 = inArray[i]; - var d1 = inArray[i + 1]; - - output[j + 0] = table[d0 >> 2]; - output[j + 1] = table[((d0 & 0x03) << 4) | (d1 >> 4)]; - output[j + 2] = table[(d1 & 0x0f) << 2]; - j += 3; - } - - break; - - case 1: - { - var d0 = inArray[i]; - - output[j + 0] = table[d0 >> 2]; - output[j + 1] = table[(d0 & 0x03) << 4]; - j += 2; - } - - break; - - // default or case 0: no further operations are needed. + ArrayPool.Shared.Return(rented); } - - if (raw) - return new string(output, 0, j); - - for (var k = j; k < output.Length; k++) +#else + byte[]? rented = null; + var utf8 = base64Length <= 512 + ? stackalloc byte[base64Length] + : (rented = ArrayPool.Shared.Rent(base64Length)).AsSpan(0, base64Length); + try { - output[k] = Base64PadCharacter; + _ = System.Buffers.Text.Base64.EncodeToUtf8(inArray, utf8, out _, out var written); + var count = MakeUrlSafe(utf8.Slice(0, written), raw); + return Encoding.ASCII.GetString(utf8.Slice(0, count)); } - - return new string(output); + finally + { + if (rented != null) + ArrayPool.Shared.Return(rented); + } +#endif } /// @@ -158,6 +115,31 @@ public static byte[] DecodeBytes(string str) return UnsafeDecode(str); } + // Translates standard base64 to base64url in place ('+' -> '-', '/' -> '_'). When raw is + // set, returns the length up to the first '=' so the trailing padding is dropped; otherwise + // returns the full length (padding kept, matching base64.URLEncoding used by other clients). + private static int MakeUrlSafe(Span utf8, bool raw) + { + for (var i = 0; i < utf8.Length; i++) + { + switch (utf8[i]) + { + case (byte)Base64Character62: + utf8[i] = (byte)Base64UrlCharacter62; + break; + case (byte)Base64Character63: + utf8[i] = (byte)Base64UrlCharacter63; + break; + case (byte)Base64PadCharacter: + if (raw) + return i; + break; + } + } + + return utf8.Length; + } + private static unsafe byte[] UnsafeDecode(string str) { var mod = str.Length % 4; diff --git a/src/NATS.Client.ObjectStore/NatsObjStore.cs b/src/NATS.Client.ObjectStore/NatsObjStore.cs index befdca53c..c9d32d0dd 100644 --- a/src/NATS.Client.ObjectStore/NatsObjStore.cs +++ b/src/NATS.Client.ObjectStore/NatsObjStore.cs @@ -96,8 +96,8 @@ public async ValueTask GetAsync(string key, Stream stream, bool { // We have to make sure to carry on consuming the channel to avoid any blocking: // e.g. if the channel is full, we would be blocking the reads off the socket (this was intentionally - // done ot avoid bloating the memory with a large backlog of messages or dropping messages at this level - // and signal the server that we are a slow consumer); then when we make an request-reply API call to + // done to avoid bloating the memory with a large backlog of messages or dropping messages at this level + // and signal the server that we are a slow consumer); then when we make a request-reply API call to // delete the consumer, the socket would be blocked trying to send the response back to us; so we need to // keep consuming the channel to avoid this. if (pushConsumer.IsDone) @@ -127,7 +127,10 @@ public async ValueTask GetAsync(string key, Stream stream, bool digest = Base64UrlEncoder.Encode(sha256.Hash); } - if ($"SHA-256={digest}" != info.Digest) + const string digestPrefix = "SHA-256="; + if (info.Digest == null + || info.Digest.StartsWith(digestPrefix, StringComparison.Ordinal) == false + || info.Digest.AsSpan(digestPrefix.Length).SequenceEqual(digest.AsSpan()) == false) { throw new NatsObjException("SHA-256 digest mismatch"); } From 0a84f513a84388c6dc8d94203a4e09831e008017 Mon Sep 17 00:00:00 2001 From: monty Date: Fri, 26 Jun 2026 11:31:04 +0100 Subject: [PATCH 02/12] Update src/NATS.Client.ObjectStore/Internal/Encoder.cs Co-authored-by: Miha Zupan --- src/NATS.Client.ObjectStore/Internal/Encoder.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/NATS.Client.ObjectStore/Internal/Encoder.cs b/src/NATS.Client.ObjectStore/Internal/Encoder.cs index d2e6f802a..a03fccf14 100644 --- a/src/NATS.Client.ObjectStore/Internal/Encoder.cs +++ b/src/NATS.Client.ObjectStore/Internal/Encoder.cs @@ -135,6 +135,12 @@ private static int MakeUrlSafe(Span utf8, bool raw) return i; break; } + utf8.Replace((byte)Base64Character62, (byte)Base64UrlCharacter62); + utf8.Replace((byte)Base64Character63, (byte)Base64UrlCharacter63); + + if (raw && utf8.EndsWith((byte)Base64PadCharacter)) + { + return utf8.LastIndexOfAnyExcept((byte)Base64PadCharacter) + 1; } return utf8.Length; From ee6f585e9aef60850ce60352e82e1f1593e279b4 Mon Sep 17 00:00:00 2001 From: Ziya Suzen Date: Fri, 26 Jun 2026 13:24:31 +0100 Subject: [PATCH 03/12] objectstore: encode base64url straight to chars The encoder built the string by encoding to UTF8 bytes and then transcoding with Encoding.ASCII.GetString. Encode straight to a char span instead and build the string from it, dropping the transcode: net9+ uses Base64Url (url-safe in one pass), net8 and netstandard2.1 use Convert.TryToBase64Chars, and netstandard2.0 keeps the byte path since it has no span-based encode-to-chars. The per-framework branches are flattened into a single #if chain. --- .../Internal/Encoder.cs | 95 +++++++++++++++---- 1 file changed, 75 insertions(+), 20 deletions(-) diff --git a/src/NATS.Client.ObjectStore/Internal/Encoder.cs b/src/NATS.Client.ObjectStore/Internal/Encoder.cs index a03fccf14..4cc5c3ddd 100644 --- a/src/NATS.Client.ObjectStore/Internal/Encoder.cs +++ b/src/NATS.Client.ObjectStore/Internal/Encoder.cs @@ -1,4 +1,7 @@ using System.Buffers; +#if NETSTANDARD2_0 || NET9_0_OR_GREATER +using System.Buffers.Text; +#endif using System.Security.Cryptography; namespace NATS.Client.ObjectStore.Internal; @@ -61,14 +64,35 @@ public static string Encode(Span inArray, bool raw = false) if (length == 0) return string.Empty; - // Base64.EncodeToUtf8 reads the input span directly (no array copy) and the output is - // ASCII, so the url-safe swap runs on the bytes and the string is built from them. var base64Length = (length + 2) / 3 * 4; -#if NETSTANDARD2_0 +#if NET9_0_OR_GREATER + // Base64Url encodes url-safe in a single pass (no separate swap). It omits padding, so for + // the default (raw == false) the trailing '=' is appended to match base64.URLEncoding. + char[]? rented = null; + var chars = base64Length <= 512 + ? stackalloc char[base64Length] + : (rented = ArrayPool.Shared.Rent(base64Length)).AsSpan(0, base64Length); + try + { + var written = Base64Url.EncodeToChars(inArray, chars); + if (raw) + return new string(chars.Slice(0, written)); + + chars.Slice(written, base64Length - written).Fill(Base64PadCharacter); + return new string(chars.Slice(0, base64Length)); + } + finally + { + if (rented != null) + ArrayPool.Shared.Return(rented); + } +#elif NETSTANDARD2_0 + // No span-based encode-to-chars on netstandard2.0: encode to ASCII bytes (read straight + // from the input span), run the url-safe swap on the bytes, then build the string. var rented = ArrayPool.Shared.Rent(base64Length); try { - _ = System.Buffers.Text.Base64.EncodeToUtf8(inArray, rented, out _, out var written); + _ = Base64.EncodeToUtf8(inArray, rented, out _, out var written); var count = MakeUrlSafe(rented.AsSpan(0, written), raw); return Encoding.ASCII.GetString(rented, 0, count); } @@ -77,20 +101,21 @@ public static string Encode(Span inArray, bool raw = false) ArrayPool.Shared.Return(rented); } #else - byte[]? rented = null; - var utf8 = base64Length <= 512 - ? stackalloc byte[base64Length] - : (rented = ArrayPool.Shared.Rent(base64Length)).AsSpan(0, base64Length); + // Encode straight to chars so the string is built without a separate ASCII transcode. + char[]? rented = null; + var chars = base64Length <= 512 + ? stackalloc char[base64Length] + : (rented = ArrayPool.Shared.Rent(base64Length)).AsSpan(0, base64Length); try { - _ = System.Buffers.Text.Base64.EncodeToUtf8(inArray, utf8, out _, out var written); - var count = MakeUrlSafe(utf8.Slice(0, written), raw); - return Encoding.ASCII.GetString(utf8.Slice(0, count)); + _ = Convert.TryToBase64Chars(inArray, chars, out var written); + var count = MakeUrlSafe(chars.Slice(0, written), raw); + return new string(chars.Slice(0, count)); } finally { if (rented != null) - ArrayPool.Shared.Return(rented); + ArrayPool.Shared.Return(rented); } #endif } @@ -115,9 +140,11 @@ public static byte[] DecodeBytes(string str) return UnsafeDecode(str); } - // Translates standard base64 to base64url in place ('+' -> '-', '/' -> '_'). When raw is - // set, returns the length up to the first '=' so the trailing padding is dropped; otherwise - // returns the full length (padding kept, matching base64.URLEncoding used by other clients). + // Translates standard base64 to base64url in place ('+' -> '-', '/' -> '_'). When raw is set, + // returns the length up to the first '=' so the trailing padding is dropped; otherwise returns + // the full length (padding kept, matching base64.URLEncoding used by other clients). net9+ + // encodes url-safe via Base64Url and needs no swap, so no helper is compiled there. +#if NETSTANDARD2_0 private static int MakeUrlSafe(Span utf8, bool raw) { for (var i = 0; i < utf8.Length; i++) @@ -135,16 +162,44 @@ private static int MakeUrlSafe(Span utf8, bool raw) return i; break; } - utf8.Replace((byte)Base64Character62, (byte)Base64UrlCharacter62); - utf8.Replace((byte)Base64Character63, (byte)Base64UrlCharacter63); + } - if (raw && utf8.EndsWith((byte)Base64PadCharacter)) + return utf8.Length; + } +#elif NETSTANDARD2_1 + private static int MakeUrlSafe(Span chars, bool raw) + { + for (var i = 0; i < chars.Length; i++) { - return utf8.LastIndexOfAnyExcept((byte)Base64PadCharacter) + 1; + switch (chars[i]) + { + case Base64Character62: + chars[i] = Base64UrlCharacter62; + break; + case Base64Character63: + chars[i] = Base64UrlCharacter63; + break; + case Base64PadCharacter: + if (raw) + return i; + break; + } } - return utf8.Length; + return chars.Length; } +#elif !NET9_0_OR_GREATER + private static int MakeUrlSafe(Span chars, bool raw) + { + chars.Replace(Base64Character62, Base64UrlCharacter62); + chars.Replace(Base64Character63, Base64UrlCharacter63); + + if (raw) + return chars.LastIndexOfAnyExcept(Base64PadCharacter) + 1; + + return chars.Length; + } +#endif private static unsafe byte[] UnsafeDecode(string str) { From c0675527af8e660cd33f16bc0a7ef8cd6f088eaf Mon Sep 17 00:00:00 2001 From: Ziya Suzen Date: Fri, 26 Jun 2026 13:24:45 +0100 Subject: [PATCH 04/12] objectstore: cover base64url encoder on all frameworks Move the encoder test into a lean project that references only the ObjectStore assembly so it can multi-target down to net6.0; the integration test project cannot, as some of its tests do not build on net6.0. Each test TFM pins a distinct compiled branch via asset selection: net6.0 -> netstandard2.1, net8.0 -> net8.0, net10.0 -> net9+, net481 -> netstandard2.0 (Windows only). Add a BenchmarkDotNet project under sandbox that compares the same branches side by side with per-runtime jobs. --- NATS.Net.slnx | 2 + .../.gitignore | 1 + .../Base64UrlEncoderBenchmarks.cs | 32 ++++++++++++++ ...ient.ObjectStore.Encoder.Benchmarks.csproj | 32 ++++++++++++++ .../Program.cs | 4 ++ .../NATS.Client.ObjectStore.csproj | 2 + .../Base64UrlEncoderTest.cs | 2 +- ...TS.Client.ObjectStore.Encoder.Tests.csproj | 43 +++++++++++++++++++ 8 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 sandbox/NATS.Client.ObjectStore.Encoder.Benchmarks/.gitignore create mode 100644 sandbox/NATS.Client.ObjectStore.Encoder.Benchmarks/Base64UrlEncoderBenchmarks.cs create mode 100644 sandbox/NATS.Client.ObjectStore.Encoder.Benchmarks/NATS.Client.ObjectStore.Encoder.Benchmarks.csproj create mode 100644 sandbox/NATS.Client.ObjectStore.Encoder.Benchmarks/Program.cs rename tests/{NATS.Client.ObjectStore.Tests => NATS.Client.ObjectStore.Encoder.Tests}/Base64UrlEncoderTest.cs (97%) create mode 100644 tests/NATS.Client.ObjectStore.Encoder.Tests/NATS.Client.ObjectStore.Encoder.Tests.csproj diff --git a/NATS.Net.slnx b/NATS.Net.slnx index 187f663d9..9bdf870dd 100644 --- a/NATS.Net.slnx +++ b/NATS.Net.slnx @@ -27,6 +27,7 @@ + @@ -82,6 +83,7 @@ + diff --git a/sandbox/NATS.Client.ObjectStore.Encoder.Benchmarks/.gitignore b/sandbox/NATS.Client.ObjectStore.Encoder.Benchmarks/.gitignore new file mode 100644 index 000000000..b0f1a2c50 --- /dev/null +++ b/sandbox/NATS.Client.ObjectStore.Encoder.Benchmarks/.gitignore @@ -0,0 +1 @@ +/BenchmarkDotNet.Artifacts diff --git a/sandbox/NATS.Client.ObjectStore.Encoder.Benchmarks/Base64UrlEncoderBenchmarks.cs b/sandbox/NATS.Client.ObjectStore.Encoder.Benchmarks/Base64UrlEncoderBenchmarks.cs new file mode 100644 index 000000000..cbbcbe3b7 --- /dev/null +++ b/sandbox/NATS.Client.ObjectStore.Encoder.Benchmarks/Base64UrlEncoderBenchmarks.cs @@ -0,0 +1,32 @@ +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Jobs; +using NATS.Client.ObjectStore.Internal; + +namespace NATS.Client.ObjectStore.Encoder.Benchmarks; + +[MemoryDiagnoser] +[SimpleJob(RuntimeMoniker.Net60)] +[SimpleJob(RuntimeMoniker.Net80)] +[SimpleJob(RuntimeMoniker.Net10_0)] +#if WINDOWS +[SimpleJob(RuntimeMoniker.Net481)] +#endif +public class Base64UrlEncoderBenchmarks +{ + private byte[] _data = null!; + + // SHA-256 digest (32) is the common case; the others bracket small and larger inputs. + [Params(16, 32, 256, 4096)] + public int Size { get; set; } + + [GlobalSetup] + public void Setup() + { + _data = new byte[Size]; + for (var i = 0; i < Size; i++) + _data[i] = (byte)((i * 37) + 11); + } + + [Benchmark] + public string Encode() => Base64UrlEncoder.Encode(_data); +} diff --git a/sandbox/NATS.Client.ObjectStore.Encoder.Benchmarks/NATS.Client.ObjectStore.Encoder.Benchmarks.csproj b/sandbox/NATS.Client.ObjectStore.Encoder.Benchmarks/NATS.Client.ObjectStore.Encoder.Benchmarks.csproj new file mode 100644 index 000000000..7054ea6c6 --- /dev/null +++ b/sandbox/NATS.Client.ObjectStore.Encoder.Benchmarks/NATS.Client.ObjectStore.Encoder.Benchmarks.csproj @@ -0,0 +1,32 @@ + + + + + Exe + net6.0;net8.0;net10.0 + $(TargetFrameworks);net481 + any;win-x64 + enable + enable + latest + false + false + true + $(NoWarn);CS8002 + $(DefineConstants);WINDOWS + + + + + + + + + + + diff --git a/sandbox/NATS.Client.ObjectStore.Encoder.Benchmarks/Program.cs b/sandbox/NATS.Client.ObjectStore.Encoder.Benchmarks/Program.cs new file mode 100644 index 000000000..813b138e5 --- /dev/null +++ b/sandbox/NATS.Client.ObjectStore.Encoder.Benchmarks/Program.cs @@ -0,0 +1,4 @@ +using System.Reflection; +using BenchmarkDotNet.Running; + +BenchmarkSwitcher.FromAssembly(Assembly.GetExecutingAssembly()).Run(args); diff --git a/src/NATS.Client.ObjectStore/NATS.Client.ObjectStore.csproj b/src/NATS.Client.ObjectStore/NATS.Client.ObjectStore.csproj index 7babd0d46..b1169e88c 100644 --- a/src/NATS.Client.ObjectStore/NATS.Client.ObjectStore.csproj +++ b/src/NATS.Client.ObjectStore/NATS.Client.ObjectStore.csproj @@ -9,6 +9,8 @@ + + diff --git a/tests/NATS.Client.ObjectStore.Tests/Base64UrlEncoderTest.cs b/tests/NATS.Client.ObjectStore.Encoder.Tests/Base64UrlEncoderTest.cs similarity index 97% rename from tests/NATS.Client.ObjectStore.Tests/Base64UrlEncoderTest.cs rename to tests/NATS.Client.ObjectStore.Encoder.Tests/Base64UrlEncoderTest.cs index f349e8c7a..cd7cd5be0 100644 --- a/tests/NATS.Client.ObjectStore.Tests/Base64UrlEncoderTest.cs +++ b/tests/NATS.Client.ObjectStore.Encoder.Tests/Base64UrlEncoderTest.cs @@ -1,7 +1,7 @@ using System.Text; using NATS.Client.ObjectStore.Internal; -namespace NATS.Client.ObjectStore.Tests; +namespace NATS.Client.ObjectStore.Encoder.Tests; public class Base64UrlEncoderTest { diff --git a/tests/NATS.Client.ObjectStore.Encoder.Tests/NATS.Client.ObjectStore.Encoder.Tests.csproj b/tests/NATS.Client.ObjectStore.Encoder.Tests/NATS.Client.ObjectStore.Encoder.Tests.csproj new file mode 100644 index 000000000..19430987f --- /dev/null +++ b/tests/NATS.Client.ObjectStore.Encoder.Tests/NATS.Client.ObjectStore.Encoder.Tests.csproj @@ -0,0 +1,43 @@ + + + + + net6.0;net8.0;net10.0 + $(TargetFrameworks);net481 + any;win-x86 + enable + enable + latest + false + + true + $(MSBuildProjectDirectory)\..\xunit.runsettings + false + true + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + From 604661193e4578309ad80d55ebc35d4f5d6e7f61 Mon Sep 17 00:00:00 2001 From: Ziya Suzen Date: Fri, 26 Jun 2026 13:24:45 +0100 Subject: [PATCH 05/12] ci: run encoder tests and target net10 sdk Run the new encoder tests on Linux (net6/8/10) and Windows (also net481). Install the net10 SDK explicitly instead of relying on the runner image happening to ship it, and drop the unused net9 SDK as no project targets net9. --- .github/workflows/docs-preview.yml | 2 +- .github/workflows/docs.yml | 2 +- .github/workflows/format.yml | 2 +- .github/workflows/perf.yml | 2 +- .github/workflows/test_linux.yml | 7 ++++++- .github/workflows/test_linux_core.yml | 2 +- .github/workflows/test_windows.yml | 8 +++++++- 7 files changed, 18 insertions(+), 7 deletions(-) diff --git a/.github/workflows/docs-preview.yml b/.github/workflows/docs-preview.yml index b5b27b217..c0c97c69d 100644 --- a/.github/workflows/docs-preview.yml +++ b/.github/workflows/docs-preview.yml @@ -24,7 +24,7 @@ jobs: dotnet-version: | 6.x 8.x - 9.x + 10.x - run: dotnet build - run: dotnet tool update -g docfx diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index dd43ee9b1..a2cf2a43e 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -28,7 +28,7 @@ jobs: dotnet-version: | 6.x 8.x - 9.x + 10.x - run: dotnet build - run: dotnet tool update -g docfx diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml index 47d50b431..a45db11f1 100644 --- a/.github/workflows/format.yml +++ b/.github/workflows/format.yml @@ -17,7 +17,7 @@ jobs: dotnet-version: | 6.x 8.x - 9.x + 10.x - name: Check formatting run: | diff --git a/.github/workflows/perf.yml b/.github/workflows/perf.yml index fa0d8fc43..55ee1ac7f 100644 --- a/.github/workflows/perf.yml +++ b/.github/workflows/perf.yml @@ -45,7 +45,7 @@ jobs: dotnet-version: | 6.x 8.x - 9.x + 10.x - name: Release Build run: dotnet build -c Release tests/NATS.Client.Perf/NATS.Client.Perf.csproj diff --git a/.github/workflows/test_linux.yml b/.github/workflows/test_linux.yml index 0cae81f63..d62759c97 100644 --- a/.github/workflows/test_linux.yml +++ b/.github/workflows/test_linux.yml @@ -44,7 +44,7 @@ jobs: dotnet-version: | 6.x 8.x - 9.x + 10.x - name: Build run: | @@ -71,6 +71,11 @@ jobs: cd tests/NATS.Client.Abstractions.Tests dotnet test -c Release --no-build + - name: Test Object Store Encoder + run: | + cd tests/NATS.Client.ObjectStore.Encoder.Tests + dotnet test -c Release --no-build + - name: Test JetStream run: | killall nats-server 2> /dev/null | echo -n diff --git a/.github/workflows/test_linux_core.yml b/.github/workflows/test_linux_core.yml index 8d90af12c..adedb84e1 100644 --- a/.github/workflows/test_linux_core.yml +++ b/.github/workflows/test_linux_core.yml @@ -44,7 +44,7 @@ jobs: dotnet-version: | 6.x 8.x - 9.x + 10.x - name: Build run: | diff --git a/.github/workflows/test_windows.yml b/.github/workflows/test_windows.yml index 44325dbc1..6c14c3107 100644 --- a/.github/workflows/test_windows.yml +++ b/.github/workflows/test_windows.yml @@ -36,7 +36,7 @@ jobs: dotnet-version: | 6.x 8.x - 9.x + 10.x - name: Set up Go uses: actions/setup-go@v5 @@ -83,6 +83,12 @@ jobs: cd tests/NATS.Client.Abstractions.Tests dotnet test -c Release --no-build + # net481 covers the netstandard2.0 branch; run TFMs serially as the platform tests do + - name: Test Object Store Encoder + run: | + cd tests/NATS.Client.ObjectStore.Encoder.Tests + dotnet test -c Release --no-build -p:TestTfmsInParallel=false + - name: Test JetStream run: | tasklist | grep -i nats-server && taskkill -F -IM nats-server.exe From aa37d90731314e5013356746bbc0ddad8a16827c Mon Sep 17 00:00:00 2001 From: Ziya Suzen Date: Fri, 26 Jun 2026 14:26:15 +0100 Subject: [PATCH 06/12] objectstore: stackalloc table encoder below net9 net9+ keeps Base64Url. Below net9 there is no url-safe base64 encoder, so use the lookup table; its 63rd/64th entries are '-'/'_', so it maps to the url-safe alphabet directly and needs no post-swap. Encode into a stack or pooled buffer instead of a fresh char[], so only the result string is allocated: half the allocations of the original encoder with no measurable CPU change, and net9+ is faster on top of that. --- .../Internal/Encoder.cs | 151 +++++++++--------- 1 file changed, 76 insertions(+), 75 deletions(-) diff --git a/src/NATS.Client.ObjectStore/Internal/Encoder.cs b/src/NATS.Client.ObjectStore/Internal/Encoder.cs index 4cc5c3ddd..19a66e8e5 100644 --- a/src/NATS.Client.ObjectStore/Internal/Encoder.cs +++ b/src/NATS.Client.ObjectStore/Internal/Encoder.cs @@ -1,5 +1,5 @@ using System.Buffers; -#if NETSTANDARD2_0 || NET9_0_OR_GREATER +#if NET9_0_OR_GREATER using System.Buffers.Text; #endif using System.Security.Cryptography; @@ -21,6 +21,16 @@ internal static class Base64UrlEncoder private const char Base64UrlCharacter62 = '-'; private const char Base64UrlCharacter63 = '_'; +#if !NET9_0_OR_GREATER + // base64url alphabet (the 63rd/64th entries are '-'/'_'), so the table encodes url-safe directly. + private static readonly char[] SBase64Table = + { + 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', + 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', + '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', Base64UrlCharacter62, Base64UrlCharacter63, + }; +#endif + public static string Sha256(ReadOnlySpan value) { Span destination = stackalloc byte[256 / 8]; @@ -86,31 +96,67 @@ public static string Encode(Span inArray, bool raw = false) if (rented != null) ArrayPool.Shared.Return(rented); } -#elif NETSTANDARD2_0 - // No span-based encode-to-chars on netstandard2.0: encode to ASCII bytes (read straight - // from the input span), run the url-safe swap on the bytes, then build the string. - var rented = ArrayPool.Shared.Rent(base64Length); - try - { - _ = Base64.EncodeToUtf8(inArray, rented, out _, out var written); - var count = MakeUrlSafe(rented.AsSpan(0, written), raw); - return Encoding.ASCII.GetString(rented, 0, count); - } - finally - { - ArrayPool.Shared.Return(rented); - } #else - // Encode straight to chars so the string is built without a separate ASCII transcode. + // Pre-net9 there is no url-safe base64 encoder, so use the lookup table (which maps directly + // to the url-safe alphabet). Encode into a stack/pooled buffer so only the result string is + // allocated. char[]? rented = null; var chars = base64Length <= 512 ? stackalloc char[base64Length] : (rented = ArrayPool.Shared.Rent(base64Length)).AsSpan(0, base64Length); + var table = SBase64Table; try { - _ = Convert.TryToBase64Chars(inArray, chars, out var written); - var count = MakeUrlSafe(chars.Slice(0, written), raw); - return new string(chars.Slice(0, count)); + var lengthMod3 = length % 3; + var limit = length - lengthMod3; + var j = 0; + + // Each 3 input bytes map to 4 output chars. + for (var i = 0; i < limit; i += 3) + { + var d0 = inArray[i]; + var d1 = inArray[i + 1]; + var d2 = inArray[i + 2]; + + chars[j + 0] = table[d0 >> 2]; + chars[j + 1] = table[((d0 & 0x03) << 4) | (d1 >> 4)]; + chars[j + 2] = table[((d1 & 0x0f) << 2) | (d2 >> 6)]; + chars[j + 3] = table[d2 & 0x3f]; + j += 4; + } + + switch (lengthMod3) + { + case 2: + { + var d0 = inArray[limit]; + var d1 = inArray[limit + 1]; + chars[j + 0] = table[d0 >> 2]; + chars[j + 1] = table[((d0 & 0x03) << 4) | (d1 >> 4)]; + chars[j + 2] = table[(d1 & 0x0f) << 2]; + j += 3; + } + + break; + + case 1: + { + var d0 = inArray[limit]; + chars[j + 0] = table[d0 >> 2]; + chars[j + 1] = table[(d0 & 0x03) << 4]; + j += 2; + } + + break; + } + + if (!raw) + { + chars.Slice(j, base64Length - j).Fill(Base64PadCharacter); + j = base64Length; + } + + return CreateString(chars.Slice(0, j)); } finally { @@ -140,64 +186,19 @@ public static byte[] DecodeBytes(string str) return UnsafeDecode(str); } - // Translates standard base64 to base64url in place ('+' -> '-', '/' -> '_'). When raw is set, - // returns the length up to the first '=' so the trailing padding is dropped; otherwise returns - // the full length (padding kept, matching base64.URLEncoding used by other clients). net9+ - // encodes url-safe via Base64Url and needs no swap, so no helper is compiled there. -#if NETSTANDARD2_0 - private static int MakeUrlSafe(Span utf8, bool raw) - { - for (var i = 0; i < utf8.Length; i++) - { - switch (utf8[i]) - { - case (byte)Base64Character62: - utf8[i] = (byte)Base64UrlCharacter62; - break; - case (byte)Base64Character63: - utf8[i] = (byte)Base64UrlCharacter63; - break; - case (byte)Base64PadCharacter: - if (raw) - return i; - break; - } - } - - return utf8.Length; - } -#elif NETSTANDARD2_1 - private static int MakeUrlSafe(Span chars, bool raw) - { - for (var i = 0; i < chars.Length; i++) - { - switch (chars[i]) - { - case Base64Character62: - chars[i] = Base64UrlCharacter62; - break; - case Base64Character63: - chars[i] = Base64UrlCharacter63; - break; - case Base64PadCharacter: - if (raw) - return i; - break; - } - } - - return chars.Length; - } -#elif !NET9_0_OR_GREATER - private static int MakeUrlSafe(Span chars, bool raw) +#if !NET9_0_OR_GREATER + // netstandard2.0 has no string(ReadOnlySpan) constructor. + private static unsafe string CreateString(ReadOnlySpan chars) { - chars.Replace(Base64Character62, Base64UrlCharacter62); - chars.Replace(Base64Character63, Base64UrlCharacter63); - - if (raw) - return chars.LastIndexOfAnyExcept(Base64PadCharacter) + 1; +#if NETSTANDARD2_0 + if (chars.IsEmpty) + return string.Empty; - return chars.Length; + fixed (char* ptr = chars) + return new string(ptr, 0, chars.Length); +#else + return new string(chars); +#endif } #endif From d27b95d6a1184ef4579d812e85942a31b6ee3cb3 Mon Sep 17 00:00:00 2001 From: Ziya Suzen Date: Fri, 26 Jun 2026 14:26:36 +0100 Subject: [PATCH 07/12] bench: compare base64url encoder on net8 and net10 Below net9 every build shares one code path (the lookup table), so net8 represents it and net10 covers Base64Url; drop the net6/net481 jobs. Keep a table baseline next to the current encoder for comparison. --- .../Base64UrlEncoderBenchmarks.cs | 75 +++++++++++++++++-- ...ient.ObjectStore.Encoder.Benchmarks.csproj | 14 +--- 2 files changed, 74 insertions(+), 15 deletions(-) diff --git a/sandbox/NATS.Client.ObjectStore.Encoder.Benchmarks/Base64UrlEncoderBenchmarks.cs b/sandbox/NATS.Client.ObjectStore.Encoder.Benchmarks/Base64UrlEncoderBenchmarks.cs index cbbcbe3b7..fcefa7017 100644 --- a/sandbox/NATS.Client.ObjectStore.Encoder.Benchmarks/Base64UrlEncoderBenchmarks.cs +++ b/sandbox/NATS.Client.ObjectStore.Encoder.Benchmarks/Base64UrlEncoderBenchmarks.cs @@ -5,14 +5,19 @@ namespace NATS.Client.ObjectStore.Encoder.Benchmarks; [MemoryDiagnoser] -[SimpleJob(RuntimeMoniker.Net60)] [SimpleJob(RuntimeMoniker.Net80)] [SimpleJob(RuntimeMoniker.Net10_0)] -#if WINDOWS -[SimpleJob(RuntimeMoniker.Net481)] -#endif public class Base64UrlEncoderBenchmarks { + private const char PadChar = '='; + + private static readonly char[] SBase64Table = + { + 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', + 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', + '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '-', '_', + }; + private byte[] _data = null!; // SHA-256 digest (32) is the common case; the others bracket small and larger inputs. @@ -27,6 +32,66 @@ public void Setup() _data[i] = (byte)((i * 37) + 11); } - [Benchmark] + // Baseline: the original lookup-table encoder that allocated a fresh char[] per call. + [Benchmark(Baseline = true, Description = "table (new char[])")] + public string EncodeTableNewArray() => EncodeTableNewArrayImpl(_data); + + [Benchmark(Description = "current")] public string Encode() => Base64UrlEncoder.Encode(_data); + + private static string EncodeTableNewArrayImpl(Span inArray) + { + var length = inArray.Length; + if (length == 0) + return string.Empty; + + var lengthMod3 = length % 3; + var limit = length - lengthMod3; + var output = new char[(length + 2) / 3 * 4]; + var table = SBase64Table; + var j = 0; + + for (var i = 0; i < limit; i += 3) + { + var d0 = inArray[i]; + var d1 = inArray[i + 1]; + var d2 = inArray[i + 2]; + + output[j + 0] = table[d0 >> 2]; + output[j + 1] = table[((d0 & 0x03) << 4) | (d1 >> 4)]; + output[j + 2] = table[((d1 & 0x0f) << 2) | (d2 >> 6)]; + output[j + 3] = table[d2 & 0x3f]; + j += 4; + } + + switch (lengthMod3) + { + case 2: + { + var d0 = inArray[limit]; + var d1 = inArray[limit + 1]; + output[j + 0] = table[d0 >> 2]; + output[j + 1] = table[((d0 & 0x03) << 4) | (d1 >> 4)]; + output[j + 2] = table[(d1 & 0x0f) << 2]; + j += 3; + } + + break; + + case 1: + { + var d0 = inArray[limit]; + output[j + 0] = table[d0 >> 2]; + output[j + 1] = table[(d0 & 0x03) << 4]; + j += 2; + } + + break; + } + + for (var k = j; k < output.Length; k++) + output[k] = PadChar; + + return new string(output); + } } diff --git a/sandbox/NATS.Client.ObjectStore.Encoder.Benchmarks/NATS.Client.ObjectStore.Encoder.Benchmarks.csproj b/sandbox/NATS.Client.ObjectStore.Encoder.Benchmarks/NATS.Client.ObjectStore.Encoder.Benchmarks.csproj index 7054ea6c6..f3ce570c4 100644 --- a/sandbox/NATS.Client.ObjectStore.Encoder.Benchmarks/NATS.Client.ObjectStore.Encoder.Benchmarks.csproj +++ b/sandbox/NATS.Client.ObjectStore.Encoder.Benchmarks/NATS.Client.ObjectStore.Encoder.Benchmarks.csproj @@ -1,24 +1,18 @@ Exe - net6.0;net8.0;net10.0 - $(TargetFrameworks);net481 - any;win-x64 + net8.0;net10.0 enable enable latest false - false - true $(NoWarn);CS8002 - $(DefineConstants);WINDOWS From c06c466e5e873d884a52abb877b17fad0f84d6c5 Mon Sep 17 00:00:00 2001 From: Ziya Suzen Date: Fri, 26 Jun 2026 14:54:22 +0100 Subject: [PATCH 08/12] bench: rename benchmark project to MicroBench2 Make it a general lean BenchmarkDotNet host (only the project under test plus BenchmarkDotNet, no heavier third-party deps like MicroBenchmark) so future minimal-dependency micro-benchmarks can be added alongside the base64url encoder one. --- NATS.Net.slnx | 2 +- .../.gitignore | 0 .../Base64UrlEncoderBenchmarks.cs | 6 +++--- .../MicroBench2.csproj} | 6 +++--- .../Program.cs | 0 src/NATS.Client.ObjectStore/NATS.Client.ObjectStore.csproj | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) rename sandbox/{NATS.Client.ObjectStore.Encoder.Benchmarks => MicroBench2}/.gitignore (100%) rename sandbox/{NATS.Client.ObjectStore.Encoder.Benchmarks => MicroBench2}/Base64UrlEncoderBenchmarks.cs (95%) rename sandbox/{NATS.Client.ObjectStore.Encoder.Benchmarks/NATS.Client.ObjectStore.Encoder.Benchmarks.csproj => MicroBench2/MicroBench2.csproj} (68%) rename sandbox/{NATS.Client.ObjectStore.Encoder.Benchmarks => MicroBench2}/Program.cs (100%) diff --git a/NATS.Net.slnx b/NATS.Net.slnx index 9bdf870dd..b6c83097e 100644 --- a/NATS.Net.slnx +++ b/NATS.Net.slnx @@ -27,7 +27,7 @@ - + diff --git a/sandbox/NATS.Client.ObjectStore.Encoder.Benchmarks/.gitignore b/sandbox/MicroBench2/.gitignore similarity index 100% rename from sandbox/NATS.Client.ObjectStore.Encoder.Benchmarks/.gitignore rename to sandbox/MicroBench2/.gitignore diff --git a/sandbox/NATS.Client.ObjectStore.Encoder.Benchmarks/Base64UrlEncoderBenchmarks.cs b/sandbox/MicroBench2/Base64UrlEncoderBenchmarks.cs similarity index 95% rename from sandbox/NATS.Client.ObjectStore.Encoder.Benchmarks/Base64UrlEncoderBenchmarks.cs rename to sandbox/MicroBench2/Base64UrlEncoderBenchmarks.cs index fcefa7017..d90541180 100644 --- a/sandbox/NATS.Client.ObjectStore.Encoder.Benchmarks/Base64UrlEncoderBenchmarks.cs +++ b/sandbox/MicroBench2/Base64UrlEncoderBenchmarks.cs @@ -2,11 +2,11 @@ using BenchmarkDotNet.Jobs; using NATS.Client.ObjectStore.Internal; -namespace NATS.Client.ObjectStore.Encoder.Benchmarks; +namespace MicroBench2; [MemoryDiagnoser] -[SimpleJob(RuntimeMoniker.Net80)] -[SimpleJob(RuntimeMoniker.Net10_0)] +[ShortRunJob(RuntimeMoniker.Net80)] +[ShortRunJob(RuntimeMoniker.Net10_0)] public class Base64UrlEncoderBenchmarks { private const char PadChar = '='; diff --git a/sandbox/NATS.Client.ObjectStore.Encoder.Benchmarks/NATS.Client.ObjectStore.Encoder.Benchmarks.csproj b/sandbox/MicroBench2/MicroBench2.csproj similarity index 68% rename from sandbox/NATS.Client.ObjectStore.Encoder.Benchmarks/NATS.Client.ObjectStore.Encoder.Benchmarks.csproj rename to sandbox/MicroBench2/MicroBench2.csproj index f3ce570c4..eb5205a0a 100644 --- a/sandbox/NATS.Client.ObjectStore.Encoder.Benchmarks/NATS.Client.ObjectStore.Encoder.Benchmarks.csproj +++ b/sandbox/MicroBench2/MicroBench2.csproj @@ -1,9 +1,9 @@ Exe diff --git a/sandbox/NATS.Client.ObjectStore.Encoder.Benchmarks/Program.cs b/sandbox/MicroBench2/Program.cs similarity index 100% rename from sandbox/NATS.Client.ObjectStore.Encoder.Benchmarks/Program.cs rename to sandbox/MicroBench2/Program.cs diff --git a/src/NATS.Client.ObjectStore/NATS.Client.ObjectStore.csproj b/src/NATS.Client.ObjectStore/NATS.Client.ObjectStore.csproj index b1169e88c..2b5b8de0d 100644 --- a/src/NATS.Client.ObjectStore/NATS.Client.ObjectStore.csproj +++ b/src/NATS.Client.ObjectStore/NATS.Client.ObjectStore.csproj @@ -10,7 +10,7 @@ - + From 553351efb7dc0767bee231c9b2b41c2d2dce0584 Mon Sep 17 00:00:00 2001 From: Ziya Suzen Date: Sat, 27 Jun 2026 17:41:38 +0100 Subject: [PATCH 09/12] objectstore: use unchecked pointer loop in table encoder --- .../Internal/Encoder.cs | 106 ++++++++---------- 1 file changed, 49 insertions(+), 57 deletions(-) diff --git a/src/NATS.Client.ObjectStore/Internal/Encoder.cs b/src/NATS.Client.ObjectStore/Internal/Encoder.cs index 19a66e8e5..86921a128 100644 --- a/src/NATS.Client.ObjectStore/Internal/Encoder.cs +++ b/src/NATS.Client.ObjectStore/Internal/Encoder.cs @@ -98,65 +98,73 @@ public static string Encode(Span inArray, bool raw = false) } #else // Pre-net9 there is no url-safe base64 encoder, so use the lookup table (which maps directly - // to the url-safe alphabet). Encode into a stack/pooled buffer so only the result string is - // allocated. + // to the url-safe alphabet). Encode into a stack/pooled buffer with an unsafe pointer loop + // (no bounds checks), so only the result string is allocated. char[]? rented = null; var chars = base64Length <= 512 ? stackalloc char[base64Length] : (rented = ArrayPool.Shared.Rent(base64Length)).AsSpan(0, base64Length); - var table = SBase64Table; try { var lengthMod3 = length % 3; var limit = length - lengthMod3; var j = 0; - // Each 3 input bytes map to 4 output chars. - for (var i = 0; i < limit; i += 3) + unsafe { - var d0 = inArray[i]; - var d1 = inArray[i + 1]; - var d2 = inArray[i + 2]; + // Pin the table and output and index them without bounds checks; inArray stays a + // checked span (its accesses are bounded by limit, derived from its own length). + fixed (char* dst = chars, tbl = SBase64Table) + { + // Each 3 input bytes map to 4 output chars. + for (var i = 0; i < limit; i += 3) + { + int d0 = inArray[i]; + int d1 = inArray[i + 1]; + int d2 = inArray[i + 2]; - chars[j + 0] = table[d0 >> 2]; - chars[j + 1] = table[((d0 & 0x03) << 4) | (d1 >> 4)]; - chars[j + 2] = table[((d1 & 0x0f) << 2) | (d2 >> 6)]; - chars[j + 3] = table[d2 & 0x3f]; - j += 4; - } + dst[j + 0] = tbl[d0 >> 2]; + dst[j + 1] = tbl[((d0 & 0x03) << 4) | (d1 >> 4)]; + dst[j + 2] = tbl[((d1 & 0x0f) << 2) | (d2 >> 6)]; + dst[j + 3] = tbl[d2 & 0x3f]; + j += 4; + } - switch (lengthMod3) - { - case 2: - { - var d0 = inArray[limit]; - var d1 = inArray[limit + 1]; - chars[j + 0] = table[d0 >> 2]; - chars[j + 1] = table[((d0 & 0x03) << 4) | (d1 >> 4)]; - chars[j + 2] = table[(d1 & 0x0f) << 2]; - j += 3; - } + switch (lengthMod3) + { + case 2: + { + int d0 = inArray[limit]; + int d1 = inArray[limit + 1]; + dst[j + 0] = tbl[d0 >> 2]; + dst[j + 1] = tbl[((d0 & 0x03) << 4) | (d1 >> 4)]; + dst[j + 2] = tbl[(d1 & 0x0f) << 2]; + j += 3; + } - break; + break; - case 1: - { - var d0 = inArray[limit]; - chars[j + 0] = table[d0 >> 2]; - chars[j + 1] = table[(d0 & 0x03) << 4]; - j += 2; - } + case 1: + { + int d0 = inArray[limit]; + dst[j + 0] = tbl[d0 >> 2]; + dst[j + 1] = tbl[(d0 & 0x03) << 4]; + j += 2; + } - break; - } + break; + } - if (!raw) - { - chars.Slice(j, base64Length - j).Fill(Base64PadCharacter); - j = base64Length; - } + if (!raw) + { + for (var k = j; k < base64Length; k++) + dst[k] = Base64PadCharacter; + j = base64Length; + } - return CreateString(chars.Slice(0, j)); + return new string(dst, 0, j); + } + } } finally { @@ -186,22 +194,6 @@ public static byte[] DecodeBytes(string str) return UnsafeDecode(str); } -#if !NET9_0_OR_GREATER - // netstandard2.0 has no string(ReadOnlySpan) constructor. - private static unsafe string CreateString(ReadOnlySpan chars) - { -#if NETSTANDARD2_0 - if (chars.IsEmpty) - return string.Empty; - - fixed (char* ptr = chars) - return new string(ptr, 0, chars.Length); -#else - return new string(chars); -#endif - } -#endif - private static unsafe byte[] UnsafeDecode(string str) { var mod = str.Length % 4; From ad224874a24f1928852fbeffce83d7f1d8a35ff8 Mon Sep 17 00:00:00 2001 From: Ziya Suzen Date: Tue, 30 Jun 2026 10:14:07 +0100 Subject: [PATCH 10/12] ci: install net6/net8/net10 sdks for apicompat The apicompat job builds the whole solution, which now includes the encoder test project targeting net6.0. Building net6.0 needs the net6.0 apphost pack, absent when only the 8.x SDK is installed. Match the test workflows and install 6.x/8.x/10.x. --- .github/workflows/apicompat.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/apicompat.yml b/.github/workflows/apicompat.yml index 8ead46507..314aa94aa 100644 --- a/.github/workflows/apicompat.yml +++ b/.github/workflows/apicompat.yml @@ -24,7 +24,10 @@ jobs: - name: Setup dotnet uses: actions/setup-dotnet@v4 with: - dotnet-version: 8.x + dotnet-version: | + 6.x + 8.x + 10.x - name: Run API Compatibility Check run: bash scripts/apicompat.sh --build From 24e9b0998a02e313c1d52c2b72dee30adab6131f Mon Sep 17 00:00:00 2001 From: Ziya Suzen Date: Tue, 30 Jun 2026 11:49:07 +0100 Subject: [PATCH 11/12] bench: add net481 to base64url encoder benchmark --- sandbox/MicroBench2/Base64UrlEncoderBenchmarks.cs | 1 + sandbox/MicroBench2/MicroBench2.csproj | 2 ++ 2 files changed, 3 insertions(+) diff --git a/sandbox/MicroBench2/Base64UrlEncoderBenchmarks.cs b/sandbox/MicroBench2/Base64UrlEncoderBenchmarks.cs index d90541180..244e103ec 100644 --- a/sandbox/MicroBench2/Base64UrlEncoderBenchmarks.cs +++ b/sandbox/MicroBench2/Base64UrlEncoderBenchmarks.cs @@ -5,6 +5,7 @@ namespace MicroBench2; [MemoryDiagnoser] +[ShortRunJob(RuntimeMoniker.Net481)] [ShortRunJob(RuntimeMoniker.Net80)] [ShortRunJob(RuntimeMoniker.Net10_0)] public class Base64UrlEncoderBenchmarks diff --git a/sandbox/MicroBench2/MicroBench2.csproj b/sandbox/MicroBench2/MicroBench2.csproj index eb5205a0a..6cdccf6bb 100644 --- a/sandbox/MicroBench2/MicroBench2.csproj +++ b/sandbox/MicroBench2/MicroBench2.csproj @@ -8,6 +8,8 @@ Exe net8.0;net10.0 + + $(TargetFrameworks);net481 enable enable latest From 08e8490b102f804db3e3154665da001a004bddb5 Mon Sep 17 00:00:00 2001 From: Ziya Suzen Date: Tue, 30 Jun 2026 11:57:02 +0100 Subject: [PATCH 12/12] objectstore: test Sha256 digest base64url encoding --- .../Base64UrlEncoderTest.cs | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/NATS.Client.ObjectStore.Encoder.Tests/Base64UrlEncoderTest.cs b/tests/NATS.Client.ObjectStore.Encoder.Tests/Base64UrlEncoderTest.cs index cd7cd5be0..922210ca4 100644 --- a/tests/NATS.Client.ObjectStore.Encoder.Tests/Base64UrlEncoderTest.cs +++ b/tests/NATS.Client.ObjectStore.Encoder.Tests/Base64UrlEncoderTest.cs @@ -1,3 +1,4 @@ +using System.Security.Cryptography; using System.Text; using NATS.Client.ObjectStore.Internal; @@ -37,6 +38,25 @@ public void Decoding_test(string input) _output.WriteLine($">>{decoded}<<"); } + [Theory] + [InlineData("")] + [InlineData("Hello World!")] + [InlineData("The quick brown fox jumps over the lazy dog")] + public void Sha256_test(string input) + { + var data = Encoding.UTF8.GetBytes(input); + var actual = Base64UrlEncoder.Sha256(data); + + // Independent reference: hash, then base64url-encode the 32-byte digest keeping the '=' pad. + using var sha256 = SHA256.Create(); + var expected = Convert.ToBase64String(sha256.ComputeHash(data)) + .Replace('+', '-') + .Replace('/', '_'); + + Assert.Equal(expected, actual); + _output.WriteLine($">>{actual}<<"); + } + private string Encode(string input, bool raw = false) { var base64String = Convert.ToBase64String(Encoding.UTF8.GetBytes(input));