This is an automated email from the ASF dual-hosted git repository.

lukaszzborek pushed a commit to branch fix/csharp-identifier-utf8-length
in repository https://gitbox.apache.org/repos/asf/iggy.git

commit 988cadddba2cfbf4a8104caad0a0467a9b235b52
Author: Łukasz Zborek <[email protected]>
AuthorDate: Sat Sep 5 22:30:42 2026 +0200

    fix(csharp): update string length validation to use UTF-8 byte count
---
 .../csharp/Iggy_SDK/Contracts/Tcp/TcpContracts.cs  |  60 +++----
 foreign/csharp/Iggy_SDK/Headers/HeaderKey.cs       |  17 +-
 foreign/csharp/Iggy_SDK/Headers/HeaderValue.cs     |  12 +-
 foreign/csharp/Iggy_SDK/Identifier.cs              |  29 +--
 foreign/csharp/Iggy_SDK/IggyClient/IIggyStream.cs  |   2 +-
 foreign/csharp/Iggy_SDK/IggyClient/IIggyTopic.cs   |   4 +-
 foreign/csharp/Iggy_SDK/Iggy_SDK.csproj            |   2 +-
 foreign/csharp/Iggy_SDK/Kinds/Partitioning.cs      |  34 ++--
 .../Iggy_SDK/Utils/TcpMessageStreamHelpers.cs      |   5 +-
 foreign/csharp/Iggy_SDK/Utils/WireName.cs          |  53 ++++++
 .../ContractsTests/WireNameLengthContractsTests.cs | 196 +++++++++++++++++++++
 .../UtilityTests/HeaderValueTests.cs               |   7 +
 .../IdentifiersByteSerializationTests.cs           |  61 +++++++
 13 files changed, 399 insertions(+), 83 deletions(-)

diff --git a/foreign/csharp/Iggy_SDK/Contracts/Tcp/TcpContracts.cs 
b/foreign/csharp/Iggy_SDK/Contracts/Tcp/TcpContracts.cs
index 74b3821d7..4a825f6d4 100644
--- a/foreign/csharp/Iggy_SDK/Contracts/Tcp/TcpContracts.cs
+++ b/foreign/csharp/Iggy_SDK/Contracts/Tcp/TcpContracts.cs
@@ -27,13 +27,14 @@ using Apache.Iggy.Extensions;
 using Apache.Iggy.Headers;
 using Apache.Iggy.Kinds;
 using Apache.Iggy.Messages;
+using Apache.Iggy.Utils;
+using Apache.Iggy.Vsr;
 using Partitioning = Apache.Iggy.Kinds.Partitioning;
 
 namespace Apache.Iggy.Contracts.Tcp;
 
 internal static class TcpContracts
 {
-    private const int MaxWireNameLength = 255;
 
     /// <summary>Frames wider than this are built on the heap instead of the 
stack.</summary>
     private const int MaxStackAllocBytes = 1024;
@@ -43,7 +44,7 @@ internal static class TcpContracts
 
     internal static byte[] LoginWithPersonalAccessToken(string token)
     {
-        var tokenLength = Encoding.UTF8.GetByteCount(token);
+        var tokenLength = WireName.ByteCount(token, nameof(token));
         Span<byte> bytes = stackalloc byte[5 + tokenLength];
         bytes[0] = (byte)tokenLength;
         Encoding.UTF8.GetBytes(token, bytes[1..(1 + tokenLength)]);
@@ -52,7 +53,7 @@ internal static class TcpContracts
 
     internal static byte[] DeletePersonalRequestToken(string name)
     {
-        var nameLength = Encoding.UTF8.GetByteCount(name);
+        var nameLength = WireName.ByteCount(name, nameof(name));
         Span<byte> bytes = stackalloc byte[5 + nameLength];
         bytes[0] = (byte)nameLength;
         Encoding.UTF8.GetBytes(name, bytes[1..(1 + nameLength)]);
@@ -61,7 +62,7 @@ internal static class TcpContracts
 
     internal static byte[] CreatePersonalAccessToken(string name, ulong? 
expiry)
     {
-        var nameLength = Encoding.UTF8.GetByteCount(name);
+        var nameLength = WireName.ByteCount(name, nameof(name));
         Span<byte> bytes = stackalloc byte[1 + nameLength + 8];
         bytes[0] = (byte)nameLength;
         Encoding.UTF8.GetBytes(name, bytes[1..(1 + nameLength)]);
@@ -94,13 +95,11 @@ internal static class TcpContracts
     {
         var bytes = new List<byte>();
 
-        var usernameBytes = Encoding.UTF8.GetBytes(userName);
-        bytes.Add((byte)usernameBytes.Length);
-        bytes.AddRange(usernameBytes);
+        bytes.Add((byte)WireName.ByteCount(userName, nameof(userName)));
+        bytes.AddRange(Encoding.UTF8.GetBytes(userName));
 
-        var passwordBytes = Encoding.UTF8.GetBytes(password);
-        bytes.Add((byte)passwordBytes.Length);
-        bytes.AddRange(passwordBytes);
+        bytes.Add((byte)WireName.ByteCount(password, nameof(password)));
+        bytes.AddRange(Encoding.UTF8.GetBytes(password));
 
         if (!string.IsNullOrEmpty(version))
         {
@@ -129,6 +128,8 @@ internal static class TcpContracts
 
     internal static byte[] ChangePassword(Identifier userId, string 
currentPassword, string newPassword)
     {
+        CredentialBounds.ValidatePassword(currentPassword);
+        CredentialBounds.ValidatePassword(newPassword);
         var currentPasswordLength = 
Encoding.UTF8.GetByteCount(currentPassword);
         var newPasswordLength = Encoding.UTF8.GetByteCount(newPassword);
         var length = userId.Length + 2 + currentPasswordLength + 
newPasswordLength + 2;
@@ -157,9 +158,15 @@ internal static class TcpContracts
 
     internal static byte[] UpdateUser(Identifier userId, string? userName, 
UserStatus? status)
     {
+        if (userName is not null)
+        {
+            CredentialBounds.ValidateUsername(userName);
+        }
+
         var userNameLength = userName is null ? 0 : 
Encoding.UTF8.GetByteCount(userName);
-        var length = userId.Length + 2 + userNameLength
-                     + (status is not null ? 2 : 1) + 1 + 1;
+        var length = userId.Length + 2
+                     + (userName is null ? 1 : 2 + userNameLength)
+                     + (status is not null ? 2 : 1);
         Span<byte> bytes = stackalloc byte[length];
 
         bytes.WriteBytesFromIdentifier(userId);
@@ -196,6 +203,8 @@ internal static class TcpContracts
     internal static byte[] CreateUser(string userName, string password, 
UserStatus status,
         Permissions? permissions = null)
     {
+        CredentialBounds.ValidateUsername(userName);
+        CredentialBounds.ValidatePassword(password);
         var userNameLength = Encoding.UTF8.GetByteCount(userName);
         var passwordLength = Encoding.UTF8.GetByteCount(password);
         var permissionsBytes = permissions is not null ? 
GetBytesFromPermissions(permissions) : [];
@@ -558,7 +567,7 @@ internal static class TcpContracts
 
     internal static byte[] CreateStream(string name)
     {
-        var nameLength = Encoding.UTF8.GetByteCount(name);
+        var nameLength = WireName.ByteCount(name, nameof(name));
         Span<byte> bytes = stackalloc byte[nameLength + 1];
         bytes[0] = (byte)nameLength;
         Encoding.UTF8.GetBytes(name, bytes[1..]);
@@ -567,7 +576,7 @@ internal static class TcpContracts
 
     internal static byte[] UpdateStream(Identifier streamId, string name)
     {
-        var nameLength = Encoding.UTF8.GetByteCount(name);
+        var nameLength = WireName.ByteCount(name, nameof(name));
         Span<byte> bytes = stackalloc byte[streamId.Length + nameLength + 3];
         bytes.WriteBytesFromIdentifier(streamId);
         var position = 2 + streamId.Length;
@@ -578,7 +587,7 @@ internal static class TcpContracts
 
     internal static byte[] CreateGroup(Identifier streamId, Identifier 
topicId, string name)
     {
-        var nameLength = Encoding.UTF8.GetByteCount(name);
+        var nameLength = WireName.ByteCount(name, nameof(name));
         Span<byte> bytes = stackalloc byte[2 + streamId.Length + 2 + 
topicId.Length + 1 + nameLength];
         bytes.WriteBytesFromStreamAndTopicIdentifiers(streamId, topicId);
         var position = 2 + streamId.Length + 2 + topicId.Length;
@@ -664,7 +673,7 @@ internal static class TcpContracts
         }
 
         var optionsLength = HeadersByteLength(options);
-        var nameLength = WireNameLength(name, nameof(name));
+        var nameLength = WireName.ByteCount(name, nameof(name));
         var length = 4 + streamId.Length + topicId.Length + 1 + nameLength + 
optionsLength;
         var rented = length > MaxStackAllocBytes ? 
ArrayPool<byte>.Shared.Rent(length) : null;
         try
@@ -718,7 +727,7 @@ internal static class TcpContracts
         }
 
         var optionsLength = HeadersByteLength(options);
-        var nameLength = WireNameLength(name, nameof(name));
+        var nameLength = WireName.ByteCount(name, nameof(name));
         var length = 2 + streamId.Length + 4 + 1 + nameLength + optionsLength;
         var rented = length > MaxStackAllocBytes ? 
ArrayPool<byte>.Shared.Rent(length) : null;
         try
@@ -742,23 +751,6 @@ internal static class TcpContracts
         }
     }
 
-    /// <summary>
-    ///     UTF-8 byte count of a length-prefixed wire name, bounded by what 
its one-byte prefix can carry.
-    /// </summary>
-    private static int WireNameLength(string name, string parameterName)
-    {
-        var length = Encoding.UTF8.GetByteCount(name);
-        if (length > MaxWireNameLength)
-        {
-            // Truncating into the prefix would ship a frame the server parses 
as a shorter
-            // name followed by garbage, instead of a request it can reject.
-            throw new ArgumentException(
-                $"{parameterName} must be at most {MaxWireNameLength} UTF-8 
bytes, got {length}.", parameterName);
-        }
-
-        return length;
-    }
-
     internal static byte[] GetTopicById(Identifier streamId, Identifier 
topicId)
     {
         Span<byte> bytes = stackalloc byte[2 + streamId.Length + 2 + 
topicId.Length];
diff --git a/foreign/csharp/Iggy_SDK/Headers/HeaderKey.cs 
b/foreign/csharp/Iggy_SDK/Headers/HeaderKey.cs
index b6a19ad21..b7fffbb24 100644
--- a/foreign/csharp/Iggy_SDK/Headers/HeaderKey.cs
+++ b/foreign/csharp/Iggy_SDK/Headers/HeaderKey.cs
@@ -16,6 +16,7 @@
 // under the License.
 
 using System.Text;
+using Apache.Iggy.Utils;
 
 namespace Apache.Iggy.Headers;
 
@@ -37,20 +38,18 @@ public readonly struct HeaderKey : IEquatable<HeaderKey>
     /// <summary>
     /// Creates a HeaderKey from a string value.
     /// </summary>
-    /// <param name="val">The string value (must be 1-255 characters).</param>
+    /// <param name="val">The string value (must be 1-255 UTF-8 bytes).</param>
     /// <returns>A new HeaderKey with String kind.</returns>
     /// <exception cref="ArgumentException">Thrown when value length is 
invalid.</exception>
     public static HeaderKey FromString(string val)
     {
-        if (val.Length is 0 or > 255)
-        {
-            throw new ArgumentException("Value has incorrect size, must be 
between 1 and 255", nameof(val));
-        }
+        var bytes = Encoding.UTF8.GetBytes(val);
+        WireName.Validate(bytes.Length, nameof(val));
 
         return new HeaderKey
         {
             Kind = HeaderKind.String,
-            Value = Encoding.UTF8.GetBytes(val)
+            Value = bytes
         };
     }
 
@@ -92,11 +91,7 @@ public readonly struct HeaderKey : IEquatable<HeaderKey>
     {
         var hash = new HashCode();
         hash.Add(Kind);
-        foreach (var b in Value)
-        {
-            hash.Add(b);
-        }
-
+        hash.AddBytes(Value);
         return hash.ToHashCode();
     }
 
diff --git a/foreign/csharp/Iggy_SDK/Headers/HeaderValue.cs 
b/foreign/csharp/Iggy_SDK/Headers/HeaderValue.cs
index d551965e5..8ce5d9f8a 100644
--- a/foreign/csharp/Iggy_SDK/Headers/HeaderValue.cs
+++ b/foreign/csharp/Iggy_SDK/Headers/HeaderValue.cs
@@ -19,6 +19,7 @@ using System.Buffers.Binary;
 using System.Globalization;
 using System.Text;
 using Apache.Iggy.Extensions;
+using Apache.Iggy.Utils;
 
 namespace Apache.Iggy.Headers;
 
@@ -42,8 +43,11 @@ public readonly struct HeaderValue
     /// </summary>
     /// <param name="value">Raw bytes</param>
     /// <returns></returns>
+    /// <exception cref="ArgumentException">Thrown when the value is empty or 
longer than 255 bytes.</exception>
     public static HeaderValue FromBytes(byte[] value)
     {
+        WireName.Validate(value.Length, nameof(value));
+
         return new HeaderValue
         {
             Kind = HeaderKind.Raw,
@@ -59,15 +63,13 @@ public readonly struct HeaderValue
     /// <exception cref="ArgumentException"></exception>
     public static HeaderValue FromString(string value)
     {
-        if (value.Length is 0 or > 255)
-        {
-            throw new ArgumentException("Value has incorrect size, must be 
between 1 and 255", nameof(value));
-        }
+        var bytes = Encoding.UTF8.GetBytes(value);
+        WireName.Validate(bytes.Length, nameof(value));
 
         return new HeaderValue
         {
             Kind = HeaderKind.String,
-            Value = Encoding.UTF8.GetBytes(value)
+            Value = bytes
         };
     }
 
diff --git a/foreign/csharp/Iggy_SDK/Identifier.cs 
b/foreign/csharp/Iggy_SDK/Identifier.cs
index 8800fc542..83fd6425f 100644
--- a/foreign/csharp/Iggy_SDK/Identifier.cs
+++ b/foreign/csharp/Iggy_SDK/Identifier.cs
@@ -18,6 +18,7 @@
 using System.Buffers.Binary;
 using System.Text;
 using Apache.Iggy.Enums;
+using Apache.Iggy.Utils;
 
 namespace Apache.Iggy;
 
@@ -34,12 +35,24 @@ public readonly struct Identifier : IEquatable<Identifier>
     /// <summary>
     ///     Identifier length in bytes.
     /// </summary>
-    public required int Length { get; init; }
+    public int Length => Value.Length;
 
     /// <summary>
-    ///     Identifier value as bytes.
+    ///     Identifier value as bytes, at most 255 of them.
     /// </summary>
-    public required byte[] Value { get; init; }
+    /// <exception cref="ArgumentOutOfRangeException">Thrown when the value is 
longer than 255 bytes.</exception>
+    public required byte[] Value
+    {
+        get => _value;
+        init
+        {
+            ArgumentNullException.ThrowIfNull(value);
+            ArgumentOutOfRangeException.ThrowIfGreaterThan(value.Length, 
WireName.MAX_LENGTH, nameof(Value));
+            _value = value;
+        }
+    }
+
+    private readonly byte[] _value;
 
     /// <summary>
     ///     Creates a numeric identifier from a value.
@@ -65,7 +78,6 @@ public readonly struct Identifier : IEquatable<Identifier>
         return new Identifier
         {
             Kind = IdKind.Numeric,
-            Length = 4,
             Value = bytes
         };
     }
@@ -78,16 +90,13 @@ public readonly struct Identifier : IEquatable<Identifier>
     /// <exception cref="ArgumentException">Thrown when the value is too long 
or too short.</exception>
     public static Identifier String(string value)
     {
-        if (value.Length is 0 or > 255)
-        {
-            throw new ArgumentException("Value has incorrect size, must be 
between 1 and 255", nameof(value));
-        }
+        var bytes = Encoding.UTF8.GetBytes(value);
+        WireName.Validate(bytes.Length, nameof(value));
 
         return new Identifier
         {
             Kind = IdKind.String,
-            Length = value.Length,
-            Value = Encoding.UTF8.GetBytes(value)
+            Value = bytes
         };
     }
 
diff --git a/foreign/csharp/Iggy_SDK/IggyClient/IIggyStream.cs 
b/foreign/csharp/Iggy_SDK/IggyClient/IIggyStream.cs
index 233139f0b..c861b9938 100644
--- a/foreign/csharp/Iggy_SDK/IggyClient/IIggyStream.cs
+++ b/foreign/csharp/Iggy_SDK/IggyClient/IIggyStream.cs
@@ -29,7 +29,7 @@ public interface IIggyStream
     ///     Creates a new stream with the specified name.
     /// </summary>
     /// <remarks>
-    ///     The stream name must be unique within the Iggy instance and has a 
maximum length of 255 characters.
+    ///     The stream name must be unique within the Iggy instance and has a 
maximum length of 255 UTF-8 bytes.
     /// </remarks>
     /// <param name="name">The unique name of the stream to create.</param>
     /// <param name="token">The cancellation token to cancel the 
operation.</param>
diff --git a/foreign/csharp/Iggy_SDK/IggyClient/IIggyTopic.cs 
b/foreign/csharp/Iggy_SDK/IggyClient/IIggyTopic.cs
index 391fdff6e..e44461e9a 100644
--- a/foreign/csharp/Iggy_SDK/IggyClient/IIggyTopic.cs
+++ b/foreign/csharp/Iggy_SDK/IggyClient/IIggyTopic.cs
@@ -55,7 +55,7 @@ public interface IIggyTopic
     ///     Additional parameters control message expiry, compression, 
replication, and maximum size.
     /// </remarks>
     /// <param name="streamId">The identifier of the stream where the topic 
will be created (numeric ID or name).</param>
-    /// <param name="name">The unique name of the topic (max 255 
characters).</param>
+    /// <param name="name">The unique name of the topic (max 255 UTF-8 
bytes).</param>
     /// <param name="partitionsCount">The number of partitions for the topic 
(max 1000).</param>
     /// <param name="compressionAlgorithm">The compression algorithm to use 
for messages (default: None).</param>
     /// <param name="messageExpiry">The message expiry period (0 for server 
default, MaxValue for never expire).</param>
@@ -86,7 +86,7 @@ public interface IIggyTopic
     /// </remarks>
     /// <param name="streamId">The identifier of the stream containing the 
topic (numeric ID or name).</param>
     /// <param name="topicId">The identifier of the topic to update (numeric 
ID or name).</param>
-    /// <param name="name">The new name for the topic (max 255 
characters).</param>
+    /// <param name="name">The new name for the topic (max 255 UTF-8 
bytes).</param>
     /// <param name="compressionAlgorithm">The new compression algorithm to 
use (default: None).</param>
     /// <param name="maxTopicSize">The new maximum size of the topic in bytes 
(0 = unlimited).</param>
     /// <param name="messageExpiry">The new message expiry period (0 for 
server default, MaxValue for never expire).</param>
diff --git a/foreign/csharp/Iggy_SDK/Iggy_SDK.csproj 
b/foreign/csharp/Iggy_SDK/Iggy_SDK.csproj
index 3cacac12e..29c45d0ed 100644
--- a/foreign/csharp/Iggy_SDK/Iggy_SDK.csproj
+++ b/foreign/csharp/Iggy_SDK/Iggy_SDK.csproj
@@ -26,7 +26,7 @@ under the License.
         <TargetFrameworks>net8.0;net10.0</TargetFrameworks>
         <AssemblyName>Apache.Iggy</AssemblyName>
         <RootNamespace>Apache.Iggy</RootNamespace>
-        <Version>0.9.0-edge.7</Version>
+        <Version>0.9.0-edge.8</Version>
         <GenerateDocumentationFile>true</GenerateDocumentationFile>
     </PropertyGroup>
 
diff --git a/foreign/csharp/Iggy_SDK/Kinds/Partitioning.cs 
b/foreign/csharp/Iggy_SDK/Kinds/Partitioning.cs
index 9efbdd46e..1cc482426 100644
--- a/foreign/csharp/Iggy_SDK/Kinds/Partitioning.cs
+++ b/foreign/csharp/Iggy_SDK/Kinds/Partitioning.cs
@@ -17,6 +17,7 @@
 
 using System.Buffers.Binary;
 using System.Text;
+using Apache.Iggy.Utils;
 
 namespace Apache.Iggy.Kinds;
 
@@ -33,12 +34,24 @@ public readonly struct Partitioning
     /// <summary>
     ///     Length of the partitioning value.
     /// </summary>
-    public required int Length { get; init; }
+    public int Length => Value.Length;
 
     /// <summary>
-    ///     Partitioning value as bytes.
+    ///     Partitioning value as bytes, at most 255 of them.
     /// </summary>
-    public required byte[] Value { get; init; }
+    /// <exception cref="ArgumentOutOfRangeException">Thrown when the value is 
longer than 255 bytes.</exception>
+    public required byte[] Value
+    {
+        get => _value;
+        init
+        {
+            ArgumentNullException.ThrowIfNull(value);
+            ArgumentOutOfRangeException.ThrowIfGreaterThan(value.Length, 
WireName.MAX_LENGTH, nameof(Value));
+            _value = value;
+        }
+    }
+
+    private readonly byte[] _value;
 
     /// <summary>
     ///     Creates a partitioning strategy that use default partitioning 
(balanced).
@@ -49,7 +62,6 @@ public readonly struct Partitioning
         return new Partitioning
         {
             Kind = Enums.Partitioning.Balanced,
-            Length = 0,
             Value = []
         };
     }
@@ -78,7 +90,6 @@ public readonly struct Partitioning
         return new Partitioning
         {
             Kind = Enums.Partitioning.PartitionId,
-            Length = 4,
             Value = bytes
         };
     }
@@ -91,16 +102,13 @@ public readonly struct Partitioning
     /// <exception cref="ArgumentException">Thrown when the value size is 
incorrect</exception>
     public static Partitioning EntityIdString(string value)
     {
-        if (value.Length is 0 or > 255)
-        {
-            throw new ArgumentException("Value has incorrect size, must be 
between 1 and 255", nameof(value));
-        }
+        var bytes = Encoding.UTF8.GetBytes(value);
+        WireName.Validate(bytes.Length, nameof(value));
 
         return new Partitioning
         {
             Kind = Enums.Partitioning.MessageKey,
-            Length = value.Length,
-            Value = Encoding.UTF8.GetBytes(value)
+            Value = bytes
         };
     }
 
@@ -120,7 +128,6 @@ public readonly struct Partitioning
         return new Partitioning
         {
             Kind = Enums.Partitioning.MessageKey,
-            Length = value.Length,
             Value = value
         };
     }
@@ -137,7 +144,6 @@ public readonly struct Partitioning
         return new Partitioning
         {
             Kind = Enums.Partitioning.MessageKey,
-            Length = 4,
             Value = bytes.ToArray()
         };
     }
@@ -154,7 +160,6 @@ public readonly struct Partitioning
         return new Partitioning
         {
             Kind = Enums.Partitioning.MessageKey,
-            Length = 8,
             Value = bytes.ToArray()
         };
     }
@@ -170,7 +175,6 @@ public readonly struct Partitioning
         return new Partitioning
         {
             Kind = Enums.Partitioning.MessageKey,
-            Length = 16,
             Value = bytes
         };
     }
diff --git a/foreign/csharp/Iggy_SDK/Utils/TcpMessageStreamHelpers.cs 
b/foreign/csharp/Iggy_SDK/Utils/TcpMessageStreamHelpers.cs
index 3aaab7c18..1edc59b04 100644
--- a/foreign/csharp/Iggy_SDK/Utils/TcpMessageStreamHelpers.cs
+++ b/foreign/csharp/Iggy_SDK/Utils/TcpMessageStreamHelpers.cs
@@ -61,10 +61,7 @@ internal static class TcpMessageStreamHelpers
             _ => throw new ArgumentOutOfRangeException()
         };
         bytes[1] = (byte)identifier.Length;
-        for (var i = 0; i < identifier.Length; i++)
-        {
-            bytes[i + 2] = identifier.Value[i];
-        }
+        identifier.Value.CopyTo(bytes[2..]);
 
         return bytes.ToArray();
     }
diff --git a/foreign/csharp/Iggy_SDK/Utils/WireName.cs 
b/foreign/csharp/Iggy_SDK/Utils/WireName.cs
new file mode 100644
index 000000000..206fcf1c4
--- /dev/null
+++ b/foreign/csharp/Iggy_SDK/Utils/WireName.cs
@@ -0,0 +1,53 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+using System.Text;
+
+namespace Apache.Iggy.Utils;
+
+/// <summary>
+///     Length rule for every length-prefixed name, password, token and header 
field on the wire,
+///     mirroring the server's <c>WireName</c> and header field limits: 1 to 
255 bytes.
+/// </summary>
+internal static class WireName
+{
+    internal const int MAX_LENGTH = 255;
+
+    /// <summary>
+    ///     Validates the UTF-8 byte count of <paramref name="value" /> and 
returns it.
+    /// </summary>
+    internal static int ByteCount(string value, string parameterName)
+    {
+        return Validate(Encoding.UTF8.GetByteCount(value), parameterName);
+    }
+
+    /// <summary>
+    ///     Validates an already computed UTF-8 byte count and returns it.
+    /// </summary>
+    internal static int Validate(int byteCount, string parameterName)
+    {
+        if (byteCount is 0 or > MAX_LENGTH)
+        {
+            // Truncating into the prefix would ship a frame the server parses 
as a shorter
+            // name followed by garbage, instead of a request it can reject.
+            throw new ArgumentException(
+                $"{parameterName} must be 1 to {MAX_LENGTH} UTF-8 bytes, got 
{byteCount}.", parameterName);
+        }
+
+        return byteCount;
+    }
+}
diff --git 
a/foreign/csharp/Iggy_SDK_Tests/ContractsTests/WireNameLengthContractsTests.cs 
b/foreign/csharp/Iggy_SDK_Tests/ContractsTests/WireNameLengthContractsTests.cs
new file mode 100644
index 000000000..30970bd21
--- /dev/null
+++ 
b/foreign/csharp/Iggy_SDK_Tests/ContractsTests/WireNameLengthContractsTests.cs
@@ -0,0 +1,196 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+using Apache.Iggy.Contracts.Tcp;
+using Apache.Iggy.Enums;
+using Apache.Iggy.Exceptions;
+using Partitioning = Apache.Iggy.Kinds.Partitioning;
+using Apache.Iggy.Vsr;
+
+namespace Apache.Iggy.Tests.ContractsTests;
+
+public sealed class WireNameLengthContractsTests
+{
+    // 129 characters, but 258 UTF-8 bytes: a character count would let this 
through.
+    private static readonly string OverLimit = new('ż', 129);
+
+    // 128 characters, exactly 255 UTF-8 bytes: the widest name the one-byte 
prefix can carry.
+    private static readonly string AtLimit = new string('ż', 127) + "a";
+
+    private static readonly Identifier Id = Identifier.Numeric(1);
+
+    public static TheoryData<string, string, Func<byte[]>> EmptyCases => new()
+    {
+        { nameof(TcpContracts.CreateStream), "name", () => 
TcpContracts.CreateStream("") },
+        { nameof(TcpContracts.UpdateStream), "name", () => 
TcpContracts.UpdateStream(Id, "") },
+        { nameof(TcpContracts.CreateGroup), "name", () => 
TcpContracts.CreateGroup(Id, Id, "") },
+        { nameof(TcpContracts.CreatePersonalAccessToken), "name", () => 
TcpContracts.CreatePersonalAccessToken("", null) },
+        { nameof(TcpContracts.DeletePersonalRequestToken), "name", () => 
TcpContracts.DeletePersonalRequestToken("") },
+        { nameof(TcpContracts.LoginWithPersonalAccessToken), "token", () => 
TcpContracts.LoginWithPersonalAccessToken("") },
+        { nameof(TcpContracts.LoginUser) + "/userName", "userName", () => 
TcpContracts.LoginUser("", "pass", null, null) },
+        { nameof(TcpContracts.LoginUser) + "/password", "password", () => 
TcpContracts.LoginUser("user", "", null, null) }
+    };
+
+    public static TheoryData<string, string, Func<byte[]>> OverLimitCases => 
new()
+    {
+        { nameof(TcpContracts.CreateStream), "name", () => 
TcpContracts.CreateStream(OverLimit) },
+        { nameof(TcpContracts.UpdateStream), "name", () => 
TcpContracts.UpdateStream(Id, OverLimit) },
+        { nameof(TcpContracts.CreateGroup), "name", () => 
TcpContracts.CreateGroup(Id, Id, OverLimit) },
+        { nameof(TcpContracts.CreatePersonalAccessToken), "name", () => 
TcpContracts.CreatePersonalAccessToken(OverLimit, null) },
+        { nameof(TcpContracts.DeletePersonalRequestToken), "name", () => 
TcpContracts.DeletePersonalRequestToken(OverLimit) },
+        { nameof(TcpContracts.LoginWithPersonalAccessToken), "token", () => 
TcpContracts.LoginWithPersonalAccessToken(OverLimit) },
+        { nameof(TcpContracts.LoginUser) + "/userName", "userName", () => 
TcpContracts.LoginUser(OverLimit, "pass", null, null) },
+        { nameof(TcpContracts.LoginUser) + "/password", "password", () => 
TcpContracts.LoginUser("user", OverLimit, null, null) }
+    };
+
+    // Each frame carries the name right after a fixed-size prefix; the offset 
says where its length byte sits.
+    public static TheoryData<string, int, Func<byte[]>> AtLimitCases => new()
+    {
+        { nameof(TcpContracts.CreateStream), 0, () => 
TcpContracts.CreateStream(AtLimit) },
+        { nameof(TcpContracts.UpdateStream), 2 + Id.Length, () => 
TcpContracts.UpdateStream(Id, AtLimit) },
+        { nameof(TcpContracts.CreateGroup), 2 * (2 + Id.Length), () => 
TcpContracts.CreateGroup(Id, Id, AtLimit) },
+        { nameof(TcpContracts.CreatePersonalAccessToken), 0, () => 
TcpContracts.CreatePersonalAccessToken(AtLimit, null) },
+        { nameof(TcpContracts.DeletePersonalRequestToken), 0, () => 
TcpContracts.DeletePersonalRequestToken(AtLimit) },
+        { nameof(TcpContracts.LoginWithPersonalAccessToken), 0, () => 
TcpContracts.LoginWithPersonalAccessToken(AtLimit) },
+        { nameof(TcpContracts.LoginUser) + "/userName", 0, () => 
TcpContracts.LoginUser(AtLimit, "pass", null, null) },
+        { nameof(TcpContracts.LoginUser) + "/password", 1 + 4, () => 
TcpContracts.LoginUser("user", AtLimit, null, null) }
+    };
+
+    // User management shares the server's credential bounds with the login 
path, not the 1-255 wire rule.
+    public static TheoryData<string, int, Func<byte[]>> CredentialCases => 
new()
+    {
+        { nameof(TcpContracts.CreateUser) + "/empty userName", 
VsrError.INVALID_USERNAME, () => TcpContracts.CreateUser("", "pass", 
UserStatus.Active) },
+        { nameof(TcpContracts.CreateUser) + "/short userName", 
VsrError.INVALID_USERNAME, () => TcpContracts.CreateUser("ab", "pass", 
UserStatus.Active) },
+        { nameof(TcpContracts.CreateUser) + "/long userName", 
VsrError.INVALID_USERNAME, () => TcpContracts.CreateUser(new string('a', 51), 
"pass", UserStatus.Active) },
+        { nameof(TcpContracts.CreateUser) + "/empty password", 
VsrError.INVALID_PASSWORD, () => TcpContracts.CreateUser("user", "", 
UserStatus.Active) },
+        { nameof(TcpContracts.CreateUser) + "/long password", 
VsrError.INVALID_PASSWORD, () => TcpContracts.CreateUser("user", new 
string('a', 101), UserStatus.Active) },
+        { nameof(TcpContracts.UpdateUser) + "/empty userName", 
VsrError.INVALID_USERNAME, () => TcpContracts.UpdateUser(Id, "", null) },
+        { nameof(TcpContracts.UpdateUser) + "/long userName", 
VsrError.INVALID_USERNAME, () => TcpContracts.UpdateUser(Id, new string('a', 
51), null) },
+        { nameof(TcpContracts.ChangePassword) + "/empty current", 
VsrError.INVALID_PASSWORD, () => TcpContracts.ChangePassword(Id, "", "new") },
+        { nameof(TcpContracts.ChangePassword) + "/long current", 
VsrError.INVALID_PASSWORD, () => TcpContracts.ChangePassword(Id, new 
string('a', 101), "new") },
+        { nameof(TcpContracts.ChangePassword) + "/empty new", 
VsrError.INVALID_PASSWORD, () => TcpContracts.ChangePassword(Id, "old", "") },
+        { nameof(TcpContracts.ChangePassword) + "/long new", 
VsrError.INVALID_PASSWORD, () => TcpContracts.ChangePassword(Id, "old", new 
string('a', 101)) }
+    };
+
+    [Theory]
+    [MemberData(nameof(OverLimitCases))]
+    public void Contract_WithAStringOverTheWireLimitInBytes_Throws(string 
contract, string parameterName,
+        Func<byte[]> serialize)
+    {
+        Assert.NotEmpty(contract);
+        var exception = Assert.Throws<ArgumentException>(serialize);
+        Assert.Equal(parameterName, exception.ParamName);
+    }
+
+    [Theory]
+    [MemberData(nameof(EmptyCases))]
+    public void Contract_WithAnEmptyString_Throws(string contract, string 
parameterName, Func<byte[]> serialize)
+    {
+        Assert.NotEmpty(contract);
+        var exception = Assert.Throws<ArgumentException>(serialize);
+        Assert.Equal(parameterName, exception.ParamName);
+    }
+
+    [Theory]
+    [MemberData(nameof(AtLimitCases))]
+    public void 
Contract_WithAStringOfExactly255Bytes_PrefixesTheFullLength(string contract, 
int prefixOffset,
+        Func<byte[]> serialize)
+    {
+        Assert.NotEmpty(contract);
+        var bytes = serialize();
+
+        Assert.Equal(255, bytes[prefixOffset]);
+        Assert.Equal((byte)'a', bytes[prefixOffset + 255]);
+    }
+
+    [Theory]
+    [MemberData(nameof(CredentialCases))]
+    public void 
UserContract_WithACredentialOutsideTheServerBounds_ThrowsTheTypedStatus(string 
contract,
+        int statusCode, Func<byte[]> serialize)
+    {
+        Assert.NotEmpty(contract);
+        var exception = 
Assert.Throws<IggyInvalidStatusCodeException>(serialize);
+        Assert.Equal(statusCode, exception.StatusCode);
+        Assert.False(exception.FromServer);
+    }
+
+    [Fact]
+    public void CreateUser_WithCredentialsAtTheServerBounds_Serializes()
+    {
+        var bytes = TcpContracts.CreateUser(new string('u', 50), new 
string('p', 100), UserStatus.Active);
+
+        Assert.Equal(50, bytes[0]);
+        Assert.Equal(100, bytes[1 + 50]);
+    }
+
+    [Fact]
+    public void LoginUser_WithEmptyVersionAndContext_Serializes()
+    {
+        var bytes = TcpContracts.LoginUser("user", "pass", "", "");
+
+        Assert.Equal(1 + 4 + 1 + 4 + 4 + 4, bytes.Length);
+    }
+
+    [Fact]
+    public void 
UpdateUser_WithStatusOnly_SerializesExactlyOneNameFlagAndStatusPair()
+    {
+        var bytes = TcpContracts.UpdateUser(Id, null, UserStatus.Inactive);
+
+        Assert.Equal(new byte[] { 1, 4, 1, 0, 0, 0, 0, 1, 
(byte)UserStatus.Inactive }, bytes);
+    }
+
+    [Fact]
+    public void UpdateUser_WithNameOnly_SerializesExactlyOneNameAndStatusFlag()
+    {
+        var bytes = TcpContracts.UpdateUser(Id, "abc", null);
+
+        Assert.Equal(new byte[] { 1, 4, 1, 0, 0, 0, 1, 3, (byte)'a', 
(byte)'b', (byte)'c', 0 }, bytes);
+    }
+
+    [Fact]
+    public void CreateStream_WithANonAsciiName_PrefixesTheUtf8ByteCount()
+    {
+        var bytes = TcpContracts.CreateStream("café");
+
+        Assert.Equal(5, bytes[0]);
+        Assert.Equal(6, bytes.Length);
+    }
+
+    [Fact]
+    public void Identifier_BuiltWithAnObjectInitializerOver255Bytes_Throws()
+    {
+        var exception = Assert.Throws<ArgumentOutOfRangeException>(() =>
+            new Identifier { Kind = IdKind.String, Value = new byte[300] });
+        Assert.Equal("Value", exception.ParamName);
+    }
+
+    [Fact]
+    public void Partitioning_BuiltWithAnObjectInitializerOver255Bytes_Throws()
+    {
+        var exception = Assert.Throws<ArgumentOutOfRangeException>(() =>
+            new Partitioning { Kind = Enums.Partitioning.MessageKey, Value = 
new byte[300] });
+        Assert.Equal("Value", exception.ParamName);
+    }
+
+    [Fact]
+    public void 
Identifier_BuiltWithAnObjectInitializerOf255Bytes_KeepsTheFullLength()
+    {
+        var identifier = new Identifier { Kind = IdKind.String, Value = new 
byte[255] };
+
+        Assert.Equal(255, identifier.Length);
+    }
+}
diff --git a/foreign/csharp/Iggy_SDK_Tests/UtilityTests/HeaderValueTests.cs 
b/foreign/csharp/Iggy_SDK_Tests/UtilityTests/HeaderValueTests.cs
index 961c6fda9..a89f7fe58 100644
--- a/foreign/csharp/Iggy_SDK_Tests/UtilityTests/HeaderValueTests.cs
+++ b/foreign/csharp/Iggy_SDK_Tests/UtilityTests/HeaderValueTests.cs
@@ -34,6 +34,13 @@ public sealed class HeaderValueTests
         Assert.Equal(data, header.Value);
     }
 
+    [Fact]
+    public void Raw_ThrowsArgumentExceptionForInvalidValue()
+    {
+        Assert.Throws<ArgumentException>(() => HeaderValue.FromBytes([]));
+        Assert.Throws<ArgumentException>(() => HeaderValue.FromBytes(new 
byte[256]));
+    }
+
     [Fact]
     public void String_ThrowsArgumentExceptionForInvalidValue()
     {
diff --git 
a/foreign/csharp/Iggy_SDK_Tests/UtilityTests/IdentifiersByteSerializationTests.cs
 
b/foreign/csharp/Iggy_SDK_Tests/UtilityTests/IdentifiersByteSerializationTests.cs
index 60aa552eb..029fa728b 100644
--- 
a/foreign/csharp/Iggy_SDK_Tests/UtilityTests/IdentifiersByteSerializationTests.cs
+++ 
b/foreign/csharp/Iggy_SDK_Tests/UtilityTests/IdentifiersByteSerializationTests.cs
@@ -15,6 +15,7 @@
 // specific language governing permissions and limitations
 // under the License.
 
+using Apache.Iggy.Headers;
 using Apache.Iggy.Kinds;
 
 namespace Apache.Iggy.Tests.UtilityTests;
@@ -30,6 +31,27 @@ public sealed class IdentifiersByteSerializationTests
         Assert.Throws<ArgumentException>(() => Identifier.String(val));
     }
 
+    [Theory]
+    [InlineData("café", 5)]
+    [InlineData("naïve-café", 12)]
+    [InlineData("日本語", 9)]
+    public void StringIdentifier_WithNonAscii_ShouldUseUtf8ByteLength(string 
value, int expectedLength)
+    {
+        var identifier = Identifier.String(value);
+
+        Assert.Equal(expectedLength, identifier.Length);
+        Assert.Equal(expectedLength, identifier.Value.Length);
+        Assert.Equal(value, identifier.GetString());
+    }
+
+    [Fact]
+    public void 
StringIdentifier_WithNonAsciiExceeding255Bytes_ShouldThrowArgumentException()
+    {
+        var val = new string('あ', 200);
+
+        Assert.Throws<ArgumentException>(() => Identifier.String(val));
+    }
+
     [Fact]
     public void KeyEntityId_WithInvalidLength_ShouldThrowArgumentException()
     {
@@ -39,6 +61,35 @@ public sealed class IdentifiersByteSerializationTests
         Assert.Throws<ArgumentException>(() => 
Partitioning.EntityIdString(val));
     }
 
+    [Theory]
+    [InlineData("café", 5)]
+    [InlineData("日本語", 9)]
+    public void KeyEntityId_WithNonAscii_ShouldUseUtf8ByteLength(string value, 
int expectedLength)
+    {
+        var partitioning = Partitioning.EntityIdString(value);
+
+        Assert.Equal(expectedLength, partitioning.Length);
+        Assert.Equal(expectedLength, partitioning.Value.Length);
+    }
+
+    [Fact]
+    public void 
KeyEntityId_WithNonAsciiExceeding255Bytes_ShouldThrowArgumentException()
+    {
+        Assert.Throws<ArgumentException>(() => Partitioning.EntityIdString(new 
string('あ', 200)));
+    }
+
+    [Fact]
+    public void 
HeaderKey_WithNonAsciiExceeding255Bytes_ShouldThrowArgumentException()
+    {
+        Assert.Throws<ArgumentException>(() => HeaderKey.FromString(new 
string('あ', 200)));
+    }
+
+    [Fact]
+    public void 
HeaderValue_WithNonAsciiExceeding255Bytes_ShouldThrowArgumentException()
+    {
+        Assert.Throws<ArgumentException>(() => HeaderValue.FromString(new 
string('あ', 200)));
+    }
+
     [Fact]
     public void KeyBytes_WithInvalidLength_ShouldThrowArgumentException()
     {
@@ -64,6 +115,16 @@ public sealed class IdentifiersByteSerializationTests
         Assert.Throws<ArgumentOutOfRangeException>(() => 
Partitioning.PartitionId(-1));
     }
 
+    [Fact]
+    public void Identifier_WithSameKindAndValue_ShouldBeEqual()
+    {
+        Assert.Equal(Identifier.Numeric(1), Identifier.Numeric(1));
+        Assert.Equal(Identifier.String("name"), Identifier.String("name"));
+        Assert.Equal(Identifier.Numeric(1).GetHashCode(), 
Identifier.Numeric(1).GetHashCode());
+        Assert.NotEqual(Identifier.Numeric(1), Identifier.Numeric(2));
+        Assert.NotEqual(Identifier.Numeric(1), Identifier.String("1"));
+    }
+
     [Fact]
     public void Consumer_WithNegativeId_ShouldThrow()
     {

Reply via email to