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 424f932821c439e7ff03ac6916bbe1cdc22e10e0 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 | 36 ++-- 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, 404 insertions(+), 85 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..50a5be646 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 }; } @@ -139,7 +148,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 && Value.AsSpan().SequenceEqual(other.Value); } /// <inheritdoc /> @@ -151,6 +160,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/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() {
