diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/BsonUniqueContentIdSerializer.IdInfo.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/BsonUniqueContentIdSerializer.IdInfo.cs new file mode 100644 index 000000000..c54c6b56d --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/BsonUniqueContentIdSerializer.IdInfo.cs @@ -0,0 +1,159 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Text; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Entities.MongoDb; + +#pragma warning disable SA1313 // Parameter names should begin with lower-case letter +#pragma warning disable RECS0082 // Parameter has the same name as a member and hides it + +public partial class BsonUniqueContentIdSerializer +{ + private readonly record struct IdInfo(int Length, bool IsGuid, Guid AsGuid, string Source) + { + public const byte GuidLength = 16; + public const byte GuidIndicator = byte.MaxValue; + public const byte LongIdIndicator = byte.MaxValue - 1; + public const byte SizeOfInt = 4; + public const byte SizeOfByte = 1; + + public bool IsEmpty => IsGuid && AsGuid == default; + + public static IdInfo Create(DomainId id) + { + var source = id.ToString(); + + if (Guid.TryParse(source, out var guid)) + { + return new IdInfo(GuidLength, true, guid, source); + } + + return new IdInfo(Encoding.UTF8.GetByteCount(source), false, default, source); + } + + public int Size(bool writeEmpty) + { + if (IsEmpty && !writeEmpty) + { + return 0; + } + + if (Length >= LongIdIndicator) + { + return SizeOfByte + SizeOfInt + Length; + } + + return SizeOfByte + Length; + } + + public int Write(Span buffer) + { + if (Length >= LongIdIndicator) + { + buffer[0] = LongIdIndicator; + + Write(buffer[1..], WriteLengthAsInt); + } + else + { + Write(buffer, WriteLengthAsByte); + } + + return Size(false); + } + + private int Write(Span buffer, WriteLength writeLength) + { + int lengthSize; + if (IsGuid) + { + // Special length indicator for all guids. + lengthSize = writeLength(buffer, GuidIndicator); + + AsGuid.TryWriteBytes(buffer[lengthSize..]); + } + else + { + // We assume that we use relatively small IDs, not longer than 253 bytes. + lengthSize = writeLength(buffer, Length); + + Encoding.UTF8.GetBytes(Source, buffer[lengthSize..]); + } + + return lengthSize + Length; + } + + public static (DomainId Id, int Length) Read(ReadOnlySpan buffer) + { + if (buffer.Length == 0) + { + return default; + } + + if (buffer[0] == LongIdIndicator) + { + var (id, read) = Read(buffer[1..], ReadLengthAsInt); + + return (id, read + 1); + } + else + { + return Read(buffer, ReadLengthAsByte); + } + } + + private static (DomainId Id, int Length) Read(ReadOnlySpan buffer, ReadLength readLength) + { + var (length, offset) = readLength(buffer); + + if (length == GuidIndicator) + { + // For guids the size is just an indicator and we use a hardcoded size. + buffer = buffer.Slice(offset, GuidLength); + + return (DomainId.Create(new Guid(buffer)), offset + GuidLength); + } + else + { + // For strings the size is correct. + buffer = buffer.Slice(offset, length); + + return (DomainId.Create(Encoding.UTF8.GetString(buffer)), offset + length); + } + } + + private static int WriteLengthAsByte(Span buffer, int length) + { + buffer[0] = (byte)length; + + return SizeOfByte; + } + + private static int WriteLengthAsInt(Span buffer, int length) + { + BitConverter.TryWriteBytes(buffer, length); + + return SizeOfInt; + } + + private static (int, int) ReadLengthAsByte(ReadOnlySpan buffer) + { + return (buffer[0], SizeOfByte); + } + + private static (int, int) ReadLengthAsInt(ReadOnlySpan buffer) + { + return (BitConverter.ToInt32(buffer), SizeOfInt); + } + + private delegate int WriteLength(Span buffer, int length); + + private delegate (int, int) ReadLength(ReadOnlySpan buffer); + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/BsonUniqueContentIdSerializer.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/BsonUniqueContentIdSerializer.cs index 7e50ba459..60dddc590 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/BsonUniqueContentIdSerializer.cs +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/BsonUniqueContentIdSerializer.cs @@ -13,9 +13,8 @@ using Squidex.Infrastructure; namespace Squidex.Domain.Apps.Entities.MongoDb; -public sealed class BsonUniqueContentIdSerializer : SerializerBase +public partial class BsonUniqueContentIdSerializer : SerializerBase { - private const byte GuidLength = 16; private static readonly BsonUniqueContentIdSerializer Instance = new BsonUniqueContentIdSerializer(); public static void Register() @@ -23,10 +22,6 @@ public sealed class BsonUniqueContentIdSerializer : SerializerBase= buffer.Length) - { - return default; - } - - var length = buffer[offset++]; - // Special length indicator for all guids. - if (length == 0xFF) - { - id = DomainId.Create(new Guid(buffer.AsSpan(offset, GuidLength))); - offset += GuidLength; - } - else - { - id = DomainId.Create(Encoding.UTF8.GetString(buffer.AsSpan(offset, length))); - offset += length; - } + var buffer = context.Reader.ReadBytes()!.AsSpan(); - return id; - } + var (appId, read) = IdInfo.Read(buffer); - return new UniqueContentId(ReadId(buffer, ref offset), ReadId(buffer, ref offset)); + return new UniqueContentId(appId, IdInfo.Read(buffer[read..]).Id); } public override void Serialize(BsonSerializationContext context, BsonSerializationArgs args, UniqueContentId value) { - var appId = CheckId(value.AppId); - - var contentId = CheckId(value.ContentId); + var appId = IdInfo.Create(value.AppId); - var isEmptyContentId = - contentId.IsGuid && - contentId.Guid == default; - - // Do not write empty Ids to the buffer to allow prefix searches. - var contentLength = !isEmptyContentId ? contentId.Length + 1 : 0; - - var bufferLength = appId.Length + 1 + contentLength; - var bufferArray = new byte[bufferLength]; - - var offset = Write(bufferArray, 0, - appId.IsGuid, - appId.Guid, - appId.Source, - appId.Length); - - if (!isEmptyContentId) + if (appId.Length >= IdInfo.LongIdIndicator) { - // Do not write the empty content id, so we can search for app as well. - Write(bufferArray, offset, - contentId.IsGuid, - contentId.Guid, - contentId.Source, - contentId.Length); + ThrowHelper.InvalidOperationException("App ID cannot be longer than 253 bytes."); } - static int Write(byte[] buffer, int offset, bool isGuid, Guid guid, string id, int idLength) - { - if (isGuid) - { - // Special length indicator for all guids. - buffer[offset++] = 0xFF; - WriteGuid(buffer.AsSpan(offset), guid); - - return offset + GuidLength; - } - else - { - // We assume that we use relatively small IDs, not longer than 254 bytes. - buffer[offset++] = (byte)idLength; - WriteString(buffer.AsSpan(offset), id); - - return offset + idLength; - } - } + var contentId = IdInfo.Create(value.ContentId); - context.Writer.WriteBytes(bufferArray); - } + var size = appId.Size(true) + contentId.Size(false); - private static (int Length, bool IsGuid, Guid Guid, string Source) CheckId(DomainId id) - { - var source = id.ToString(); + var bufferArray = new byte[size]; + var bufferSpan = bufferArray.AsSpan(); - var idIsGuid = Guid.TryParse(source, out var idGuid); - var idLength = GuidLength; + var written = appId.Write(bufferSpan); - if (!idIsGuid) + if (!contentId.IsEmpty) { - idLength = (byte)Encoding.UTF8.GetByteCount(source); - - // We only use a single byte to write the length, therefore we do not allow large strings. - if (idLength > 254) - { - ThrowHelper.InvalidOperationException("Cannot write long IDs."); - } + // Do not write empty Ids to the buffer to allow prefix searches. + contentId.Write(bufferSpan[written..]); } - return (idLength, idIsGuid, idGuid, source); - } - - private static void WriteString(Span span, string id) - { - Encoding.UTF8.GetBytes(id, span); - } - - private static void WriteGuid(Span span, Guid guid) - { - guid.TryWriteBytes(span); + context.Writer.WriteBytes(bufferArray); } } diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Text/BsonUniqueContentIdSerializerTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Text/BsonUniqueContentIdSerializerTests.cs index 0a6bb118d..88c229190 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Text/BsonUniqueContentIdSerializerTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Text/BsonUniqueContentIdSerializerTests.cs @@ -5,6 +5,7 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using MongoDB.Bson.Serialization.Attributes; using Squidex.Domain.Apps.Core.TestHelpers; using Squidex.Domain.Apps.Entities.MongoDb; using Squidex.Infrastructure; @@ -18,6 +19,14 @@ public class BsonUniqueContentIdSerializerTests BsonUniqueContentIdSerializer.Register(); } + public static readonly TheoryData CustomIds = new TheoryData + { + "id", + "id-short", + "id-and-guid-size", + "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua." + }; + [Fact] public void Should_serialize_and_deserialize_guid_guid() { @@ -28,10 +37,11 @@ public class BsonUniqueContentIdSerializerTests Assert.Equal(source, deserialized); } - [Fact] - public void Should_serialize_and_deserialize_guid_custom() + [Theory] + [MemberData(nameof(CustomIds))] + public void Should_serialize_and_deserialize_guid_custom(string id) { - var source = new UniqueContentId(DomainId.NewGuid(), DomainId.Create("id42")); + var source = new UniqueContentId(DomainId.NewGuid(), DomainId.Create(id)); var deserialized = source.SerializeAndDeserializeBson(); @@ -48,10 +58,22 @@ public class BsonUniqueContentIdSerializerTests Assert.Equal(source, deserialized); } - [Fact] - public void Should_serialize_and_deserialize_custom_custom() + [Theory] + [MemberData(nameof(CustomIds))] + public void Should_serialize_and_deserialize_custom_custom(string id) + { + var source = new UniqueContentId(DomainId.Create(id), DomainId.Create(id)); + + var deserialized = source.SerializeAndDeserializeBson(); + + Assert.Equal(source, deserialized); + } + + [Theory] + [MemberData(nameof(CustomIds))] + public void Should_serialize_and_deserialize_custom_guid(string id) { - var source = new UniqueContentId(DomainId.Create("id41"), DomainId.Create("id42")); + var source = new UniqueContentId(DomainId.Create(id), DomainId.NewGuid()); var deserialized = source.SerializeAndDeserializeBson(); @@ -59,15 +81,35 @@ public class BsonUniqueContentIdSerializerTests } [Fact] - public void Should_serialize_and_deserialize_custom_guid() + public void Should_serialize_very_long_content_id() { - var source = new UniqueContentId(DomainId.Create("id42"), DomainId.NewGuid()); + var source = new UniqueContentId(DomainId.NewGuid(), DomainId.Create(new string('x', 512))); var deserialized = source.SerializeAndDeserializeBson(); Assert.Equal(source, deserialized); } + [Fact] + public void Should_not_serialize_very_long_app_id() + { + var source = new UniqueContentId(DomainId.Create(new string('x', 512)), DomainId.NewGuid()); + + var exception = Assert.ThrowsAny(() => source.SerializeAndDeserializeBson()); + + Assert.Contains("App ID cannot be longer than 253 bytes.", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void Should_not_serialize_long_app_id() + { + var source = new UniqueContentId(DomainId.Create(new string('x', 512)), DomainId.NewGuid()); + + var exception = Assert.ThrowsAny(() => source.SerializeAndDeserializeBson()); + + Assert.Contains("App ID cannot be longer than 253 bytes.", exception.Message, StringComparison.Ordinal); + } + [Fact] public void Should_calculate_next_custom_id() {