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

spetz pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/iggy.git


The following commit(s) were added to refs/heads/master by this push:
     new 7b78014c9 fix(csharp): update string length validation to use UTF-8 
byte count (#4072)
7b78014c9 is described below

commit 7b78014c938bf52331f1d34ec5552047b4b1e7ee
Author: Łukasz Zborek <[email protected]>
AuthorDate: Sun Sep 6 23:31:57 2026 +0200

    fix(csharp): update string length validation to use UTF-8 byte count (#4072)
    
    Identifier, Partitioning and header types checked string.Length,
    so a non-ASCII name under 255 characters could exceed 255 bytes
    and be truncated into the one byte length prefix, sending a
    frame the server parses as a shorter name followed by garbage.
    Most TcpContracts skipped the check entirely. Identifier also
    compared and hashed Value by reference.
    
    Every length-prefixed name, credential, token and header field
    now goes through one WireName rule mirroring the server's 1 to
    255 byte bound. Identifier and Partitioning derive Length from
    Value and reject oversize arrays in the initializer. Identifier
    equality uses byte contents. User contracts validate credentials
    against CredentialBounds before serializing.
    
    Contract tests cover empty, at-limit, over-limit and non-ASCII
    inputs for each entry point.
    
    #4056
---
 .../csharp/Iggy_SDK/Contracts/Tcp/TcpContracts.cs  |  60 +++---
 foreign/csharp/Iggy_SDK/Extensions/Extensions.cs   |   8 +-
 foreign/csharp/Iggy_SDK/Headers/HeaderKey.cs       |  17 +-
 foreign/csharp/Iggy_SDK/Headers/HeaderValue.cs     |  12 +-
 foreign/csharp/Iggy_SDK/Identifier.cs              |  56 ++++--
 foreign/csharp/Iggy_SDK/IggyClient/IIggyStream.cs  |   2 +-
 foreign/csharp/Iggy_SDK/IggyClient/IIggyTopic.cs   |   4 +-
 .../Implementations/HttpMessageStream.cs           |   2 +-
 foreign/csharp/Iggy_SDK/Iggy_SDK.csproj            |   2 +-
 foreign/csharp/Iggy_SDK/Kinds/Partitioning.cs      |  46 +++--
 .../Iggy_SDK/Utils/TcpMessageStreamHelpers.cs      |   5 +-
 foreign/csharp/Iggy_SDK/Utils/WireName.cs          |  53 +++++
 .../ContractsTests/WireNameLengthContractsTests.cs | 217 +++++++++++++++++++++
 .../UtilityTests/HeaderValueTests.cs               |   7 +
 .../IdentifiersByteSerializationTests.cs           | 131 +++++++++++++
 15 files changed, 526 insertions(+), 96 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/Extensions/Extensions.cs 
b/foreign/csharp/Iggy_SDK/Extensions/Extensions.cs
index 3e2063a3c..137279ba9 100644
--- a/foreign/csharp/Iggy_SDK/Extensions/Extensions.cs
+++ b/foreign/csharp/Iggy_SDK/Extensions/Extensions.cs
@@ -75,12 +75,12 @@ internal static class Extensions
     {
         bytes[startPos] = streamId.Kind.GetByte();
         bytes[startPos + 1] = (byte)streamId.Length;
-        streamId.Value.CopyTo(bytes[(startPos + 2)..(startPos + 2 + 
streamId.Length)]);
+        streamId.Bytes.CopyTo(bytes[(startPos + 2)..(startPos + 2 + 
streamId.Length)]);
 
         var position = startPos + 2 + streamId.Length;
         bytes[position] = topicId.Kind.GetByte();
         bytes[position + 1] = (byte)topicId.Length;
-        topicId.Value.CopyTo(bytes[(position + 2)..(position + 2 + 
topicId.Length)]);
+        topicId.Bytes.CopyTo(bytes[(position + 2)..(position + 2 + 
topicId.Length)]);
     }
 
     [MethodImpl(MethodImplOptions.AggressiveInlining)]
@@ -88,7 +88,7 @@ internal static class Extensions
     {
         bytes[startPos + 0] = identifier.Kind.GetByte();
         bytes[startPos + 1] = (byte)identifier.Length;
-        identifier.Value.CopyTo(bytes[(startPos + 2)..]);
+        identifier.Bytes.CopyTo(bytes[(startPos + 2)..]);
     }
 
     [MethodImpl(MethodImplOptions.AggressiveInlining)]
@@ -96,7 +96,7 @@ internal static class Extensions
     {
         bytes[startPos + 0] = identifier.Kind.GetByte();
         bytes[startPos + 1] = (byte)identifier.Length;
-        identifier.Value.CopyTo(bytes[(startPos + 2)..]);
+        identifier.Bytes.CopyTo(bytes[(startPos + 2)..]);
     }
 }
 
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..69e369289 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;
 
@@ -32,14 +33,36 @@ public readonly struct Identifier : IEquatable<Identifier>
     public required IdKind Kind { get; init; }
 
     /// <summary>
-    ///     Identifier length in bytes.
+    ///     Identifier length in bytes, always derived from <see cref="Value" 
/>.
+    ///     The initializer is kept for compatibility and its value is ignored.
     /// </summary>
-    public required int Length { get; init; }
+    public int Length
+    {
+        get => _value.Length;
+        init { }
+    }
 
     /// <summary>
-    ///     Identifier value as bytes.
+    ///     Copy of the 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.ToArray();
+        init
+        {
+            ArgumentNullException.ThrowIfNull(value);
+            ArgumentOutOfRangeException.ThrowIfGreaterThan(value.Length, 
WireName.MAX_LENGTH, nameof(Value));
+            _value = value.ToArray();
+        }
+    }
+
+    /// <summary>
+    ///     Read-only view of the value bytes for serialization, without the 
defensive copy of <see cref="Value" />.
+    /// </summary>
+    internal ReadOnlySpan<byte> Bytes => _value;
+
+    private readonly byte[] _value;
 
     /// <summary>
     ///     Creates a numeric identifier from a value.
@@ -65,7 +88,6 @@ public readonly struct Identifier : IEquatable<Identifier>
         return new Identifier
         {
             Kind = IdKind.Numeric,
-            Length = 4,
             Value = bytes
         };
     }
@@ -78,16 +100,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
         };
     }
 
@@ -96,8 +115,8 @@ public readonly struct Identifier : IEquatable<Identifier>
     {
         return Kind switch
         {
-            IdKind.Numeric => BitConverter.ToInt32(Value).ToString(),
-            IdKind.String => Encoding.UTF8.GetString(Value),
+            IdKind.Numeric => BitConverter.ToInt32(_value).ToString(),
+            IdKind.String => Encoding.UTF8.GetString(_value),
             _ => throw new ArgumentOutOfRangeException()
         };
     }
@@ -114,7 +133,7 @@ public readonly struct Identifier : IEquatable<Identifier>
             throw new InvalidOperationException("Identifier is not numeric");
         }
 
-        return BinaryPrimitives.ReadUInt32LittleEndian(Value);
+        return BinaryPrimitives.ReadUInt32LittleEndian(_value);
     }
 
     /// <summary>
@@ -129,7 +148,7 @@ public readonly struct Identifier : IEquatable<Identifier>
             throw new InvalidOperationException("Identifier is not string");
         }
 
-        return Encoding.UTF8.GetString(Value);
+        return Encoding.UTF8.GetString(_value);
     }
 
     /// <summary>
@@ -139,7 +158,7 @@ public readonly struct Identifier : IEquatable<Identifier>
     /// <returns>True if the current identifier is equal to the other 
identifier; otherwise, false.</returns>
     public bool Equals(Identifier other)
     {
-        return Kind == other.Kind && Value.Equals(other.Value);
+        return Kind == other.Kind && Bytes.SequenceEqual(other.Bytes);
     }
 
     /// <inheritdoc />
@@ -151,6 +170,9 @@ public readonly struct Identifier : IEquatable<Identifier>
     /// <inheritdoc />
     public override int GetHashCode()
     {
-        return HashCode.Combine((int)Kind, Value);
+        var hash = new HashCode();
+        hash.Add(Kind);
+        hash.AddBytes(_value);
+        return hash.ToHashCode();
     }
 }
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/IggyClient/Implementations/HttpMessageStream.cs 
b/foreign/csharp/Iggy_SDK/IggyClient/Implementations/HttpMessageStream.cs
index 8310f26c7..61fe4ce5f 100644
--- a/foreign/csharp/Iggy_SDK/IggyClient/Implementations/HttpMessageStream.cs
+++ b/foreign/csharp/Iggy_SDK/IggyClient/Implementations/HttpMessageStream.cs
@@ -999,7 +999,7 @@ public class HttpMessageStream : IIggyClient
         var partition = partitioning.Kind switch
         {
             Enums.Partitioning.Balanced => 
_groupState.NextBalancedPartition(key, partitionCount.Value),
-            Enums.Partitioning.MessageKey => 
XxHash32.HashToUInt32(partitioning.Value) % partitionCount.Value,
+            Enums.Partitioning.MessageKey => 
XxHash32.HashToUInt32(partitioning.Bytes) % partitionCount.Value,
             _ => throw new FeatureUnavailableException()
         };
 
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..662e50f17 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;
 
@@ -31,14 +32,36 @@ public readonly struct Partitioning
     public required Enums.Partitioning Kind { get; init; }
 
     /// <summary>
-    ///     Length of the partitioning value.
+    ///     Length of the partitioning value in bytes, always derived from 
<see cref="Value" />.
+    ///     The initializer is kept for compatibility and its value is ignored.
     /// </summary>
-    public required int Length { get; init; }
+    public int Length
+    {
+        get => _value.Length;
+        init { }
+    }
 
     /// <summary>
-    ///     Partitioning value as bytes.
+    ///     Copy of the 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.ToArray();
+        init
+        {
+            ArgumentNullException.ThrowIfNull(value);
+            ArgumentOutOfRangeException.ThrowIfGreaterThan(value.Length, 
WireName.MAX_LENGTH, nameof(Value));
+            _value = value.ToArray();
+        }
+    }
+
+    /// <summary>
+    ///     Read-only view of the value bytes for serialization, without the 
defensive copy of <see cref="Value" />.
+    /// </summary>
+    internal ReadOnlySpan<byte> Bytes => _value;
+
+    private readonly byte[] _value;
 
     /// <summary>
     ///     Creates a partitioning strategy that use default partitioning 
(balanced).
@@ -49,7 +72,6 @@ public readonly struct Partitioning
         return new Partitioning
         {
             Kind = Enums.Partitioning.Balanced,
-            Length = 0,
             Value = []
         };
     }
@@ -78,7 +100,6 @@ public readonly struct Partitioning
         return new Partitioning
         {
             Kind = Enums.Partitioning.PartitionId,
-            Length = 4,
             Value = bytes
         };
     }
@@ -91,16 +112,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 +138,6 @@ public readonly struct Partitioning
         return new Partitioning
         {
             Kind = Enums.Partitioning.MessageKey,
-            Length = value.Length,
             Value = value
         };
     }
@@ -137,7 +154,6 @@ public readonly struct Partitioning
         return new Partitioning
         {
             Kind = Enums.Partitioning.MessageKey,
-            Length = 4,
             Value = bytes.ToArray()
         };
     }
@@ -154,7 +170,6 @@ public readonly struct Partitioning
         return new Partitioning
         {
             Kind = Enums.Partitioning.MessageKey,
-            Length = 8,
             Value = bytes.ToArray()
         };
     }
@@ -170,7 +185,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..030d79ab2 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.Bytes.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..0b62ec86c
--- /dev/null
+++ 
b/foreign/csharp/Iggy_SDK_Tests/ContractsTests/WireNameLengthContractsTests.cs
@@ -0,0 +1,217 @@
+// 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 GetUser_WithANonAsciiStringIdentifier_SerializesTheUtf8Bytes()
+    {
+        var bytes = TcpContracts.GetUser(Identifier.String("café"));
+
+        Assert.Equal(new byte[] { 2, 5, (byte)'c', (byte)'a', (byte)'f', 0xC3, 
0xA9 }, bytes);
+    }
+
+    [Fact]
+    public void 
UpdateStream_WithANonAsciiStringIdentifier_PlacesTheNameAfterTheUtf8Bytes()
+    {
+        var bytes = TcpContracts.UpdateStream(Identifier.String("café"), 
"topic");
+
+        Assert.Equal(
+            new byte[]
+            {
+                2, 5, (byte)'c', (byte)'a', (byte)'f', 0xC3, 0xA9,
+                5, (byte)'t', (byte)'o', (byte)'p', (byte)'i', (byte)'c'
+            }, bytes);
+    }
+
+    [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..6e1235b38 100644
--- 
a/foreign/csharp/Iggy_SDK_Tests/UtilityTests/IdentifiersByteSerializationTests.cs
+++ 
b/foreign/csharp/Iggy_SDK_Tests/UtilityTests/IdentifiersByteSerializationTests.cs
@@ -15,7 +15,10 @@
 // specific language governing permissions and limitations
 // under the License.
 
+using Apache.Iggy.Enums;
 using Apache.Iggy.Kinds;
+using Partitioning = Apache.Iggy.Kinds.Partitioning;
+using Apache.Iggy.Headers;
 
 namespace Apache.Iggy.Tests.UtilityTests;
 
@@ -30,6 +33,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 +63,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,10 +117,88 @@ 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()
     {
         Assert.Throws<ArgumentOutOfRangeException>(() => Consumer.New(-1));
         Assert.Throws<ArgumentOutOfRangeException>(() => Consumer.Group(-1));
     }
+
+    [Fact]
+    public void 
Identifier_BuiltWithALegacyLengthInitializer_DerivesLengthFromValue()
+    {
+        var identifier = new Identifier { Kind = IdKind.String, Length = 1, 
Value = "café"u8.ToArray() };
+
+        Assert.Equal(5, identifier.Length);
+        Assert.Equal(Identifier.String("café"), identifier);
+    }
+
+    [Fact]
+    public void 
Partitioning_BuiltWithALegacyLengthInitializer_DerivesLengthFromValue()
+    {
+        var partitioning = new Partitioning
+        {
+            Kind = Enums.Partitioning.MessageKey,
+            Length = 1,
+            Value = "café"u8.ToArray()
+        };
+
+        Assert.Equal(5, partitioning.Length);
+    }
+
+    [Fact]
+    public void 
Identifier_WhenTheInitializerArrayIsMutated_KeepsTheOriginalValue()
+    {
+        var bytes = "abc"u8.ToArray();
+        var identifier = new Identifier { Kind = IdKind.String, Value = bytes 
};
+        var lookup = new HashSet<Identifier> { identifier };
+
+        bytes[0] = (byte)'z';
+
+        Assert.Equal("abc", identifier.GetString());
+        Assert.Contains(Identifier.String("abc"), lookup);
+    }
+
+    [Fact]
+    public void Identifier_WhenTheValueCopyIsMutated_KeepsTheOriginalValue()
+    {
+        var identifier = Identifier.String("abc");
+        var lookup = new HashSet<Identifier> { identifier };
+
+        identifier.Value[0] = (byte)'z';
+
+        Assert.Equal("abc", identifier.GetString());
+        Assert.Contains(Identifier.String("abc"), lookup);
+    }
+
+    [Fact]
+    public void 
Partitioning_WhenTheInitializerArrayIsMutated_KeepsTheOriginalValue()
+    {
+        var bytes = "abc"u8.ToArray();
+        var partitioning = Partitioning.EntityIdBytes(bytes);
+
+        bytes[0] = (byte)'z';
+
+        Assert.Equal("abc"u8.ToArray(), partitioning.Value);
+    }
+
+    [Fact]
+    public void Partitioning_WhenTheValueCopyIsMutated_KeepsTheOriginalValue()
+    {
+        var partitioning = Partitioning.EntityIdString("abc");
+
+        partitioning.Value[0] = (byte)'z';
+
+        Assert.Equal("abc"u8.ToArray(), partitioning.Value);
+    }
 }

Reply via email to