You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@iggy.apache.org by lz...@apache.org on 2026/09/05 21:01:02 UTC

(iggy) branch fix/csharp-identifier-utf8-length updated (424f93282 -> 988cadddb)

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

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


 discard 424f93282 fix(csharp): update string length validation to use UTF-8 byte count
     new 988cadddb fix(csharp): update string length validation to use UTF-8 byte count

This update added new revisions after undoing existing revisions.
That is to say, some revisions that were in the old version of the
branch are not in the new version.  This situation occurs
when a user --force pushes a change and generates a repository
containing something like this:

 * -- * -- B -- O -- O -- O   (424f93282)
            \
             N -- N -- N   refs/heads/fix/csharp-identifier-utf8-length (988cadddb)

You should already have received notification emails for all of the O
revisions, and so the following emails describe only the N revisions
from the common base, B.

Any revisions marked "omit" are not gone; other references still
refer to them.  Any revisions marked "discard" are gone forever.

The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.


Summary of changes:
 foreign/csharp/Iggy_SDK/Identifier.cs | 7 ++-----
 1 file changed, 2 insertions(+), 5 deletions(-)


(iggy) 01/01: fix(csharp): update string length validation to use UTF-8 byte count

Posted by lz...@apache.org.
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 <lu...@gmail.com>
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()
     {