diff --git a/src/Components/Endpoints/src/Rendering/SSRRenderModeBoundary.cs b/src/Components/Endpoints/src/Rendering/SSRRenderModeBoundary.cs index 9695ccbe3d99..e3d498d139bd 100644 --- a/src/Components/Endpoints/src/Rendering/SSRRenderModeBoundary.cs +++ b/src/Components/Endpoints/src/Rendering/SSRRenderModeBoundary.cs @@ -5,8 +5,6 @@ using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Globalization; -using System.Security.Cryptography; -using System.Text; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Components.Rendering; using Microsoft.AspNetCore.Components.Web; @@ -192,7 +190,7 @@ public ComponentMarker ToMarker(HttpContext httpContext, int sequence, object? c private ComponentMarkerKey GenerateMarkerKey(int sequence, object? componentKey) { - var componentTypeNameHash = _componentTypeNameHashCache.GetOrAdd(_componentType, ComputeComponentTypeNameHash); + var componentTypeNameHash = _componentTypeNameHashCache.GetOrAdd(_componentType, TypeNameHash.Compute); var sequenceString = sequence.ToString(CultureInfo.InvariantCulture); var locationHash = $"{componentTypeNameHash}:{sequenceString}"; @@ -204,24 +202,4 @@ private ComponentMarkerKey GenerateMarkerKey(int sequence, object? componentKey) FormattedComponentKey = formattedComponentKey, }; } - - private static string ComputeComponentTypeNameHash(Type componentType) - { - if (componentType.FullName is not { } typeName) - { - throw new InvalidOperationException($"An invalid component type was used in {nameof(SSRRenderModeBoundary)}."); - } - - var typeNameLength = typeName.Length; - var typeNameBytes = typeNameLength < 1024 - ? stackalloc byte[typeNameLength] - : new byte[typeNameLength]; - - Encoding.UTF8.GetBytes(typeName, typeNameBytes); - - Span typeNameHashBytes = stackalloc byte[SHA1.HashSizeInBytes]; - SHA1.HashData(typeNameBytes, typeNameHashBytes); - - return Convert.ToHexString(typeNameHashBytes); - } } diff --git a/src/Components/Endpoints/src/Rendering/TypeNameHash.cs b/src/Components/Endpoints/src/Rendering/TypeNameHash.cs new file mode 100644 index 000000000000..048551ee5d83 --- /dev/null +++ b/src/Components/Endpoints/src/Rendering/TypeNameHash.cs @@ -0,0 +1,34 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Security.Cryptography; +using System.Text; + +namespace Microsoft.AspNetCore.Components.Endpoints; + +// Internal for testing. +internal class TypeNameHash +{ + public const int MaxStackBufferSize = 1024; + + public static string Compute(Type type) + { + if (type.FullName is not { } typeName) + { + throw new InvalidOperationException($"Cannot compute a hash for a type without a {nameof(Type.FullName)}."); + } + + Span typeNameBytes = stackalloc byte[MaxStackBufferSize]; + + if (!Encoding.UTF8.TryGetBytes(typeName, typeNameBytes, out var written)) + { + typeNameBytes = Encoding.UTF8.GetBytes(typeName); + written = typeNameBytes.Length; + } + + Span typeNameHashBytes = stackalloc byte[SHA256.HashSizeInBytes]; + SHA256.HashData(typeNameBytes[..written], typeNameHashBytes); + + return Convert.ToHexString(typeNameHashBytes); + } +} diff --git a/src/Components/Endpoints/test/TypeNameHashTest.cs b/src/Components/Endpoints/test/TypeNameHashTest.cs new file mode 100644 index 000000000000..92fdf08bd009 --- /dev/null +++ b/src/Components/Endpoints/test/TypeNameHashTest.cs @@ -0,0 +1,81 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Security.Cryptography; + +namespace Microsoft.AspNetCore.Components.Endpoints; + +public class TypeNameHashTest +{ + // In these tests, we're mostly interested in checking that the hash function succeeds + // for any type with a valid name. We'll also do some basic sanity checking by ensuring + // that the string representation of the hash has the expected length. + + // We currently use a hex-encoded SHA256 hash, so there should be two characters per byte + // of encoded data. + private const int ExpectedHashLength = SHA256.HashSizeInBytes * 2; + + [Fact] + public void CanComputeHashForTypeWithBasicName() + { + // Act + var hash = TypeNameHash.Compute(typeof(ClassWithBasicName)); + + // Assert + Assert.Equal(ExpectedHashLength, hash.Length); + } + + [Fact] + public void CanComputeHashForTypeWithMultibyteCharacters() + { + // Act + var hash = TypeNameHash.Compute(typeof(ClássWïthMûltibyteÇharacters)); + + // Assert + Assert.Equal(ExpectedHashLength, hash.Length); + } + + [Fact] + public void CanComputeHashForAnonymousType() + { + // Arrange + var type = new { Foo = "bar" }.GetType(); + + // Act + var hash = TypeNameHash.Compute(type); + + // Assert + Assert.Equal(ExpectedHashLength, hash.Length); + } + + [Fact] + public void CanComputeHashForTypeWithNameLongerThanMaxStackBufferSize() + { + // Arrange + // We need to use a type with a long name, so we'll use a large tuple. + // We have an assert later in this test to sanity check that the type + // name is indeed longer than the max stack buffer size. + var type = (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12).GetType(); + + // Act + var hash = TypeNameHash.Compute(type); + + // Assert + Assert.True(type.FullName.Length > TypeNameHash.MaxStackBufferSize); + Assert.Equal(ExpectedHashLength, hash.Length); + } + + [Fact] + public void ThrowsIfTypeHasNoName() + { + // Arrange + var type = typeof(Nullable<>).GetGenericArguments()[0]; + + // Act/Assert + var ex = Assert.Throws(() => TypeNameHash.Compute(type)); + Assert.Equal($"Cannot compute a hash for a type without a {nameof(Type.FullName)}.", ex.Message); + } + + class ClassWithBasicName; + class ClássWïthMûltibyteÇharacters; +} diff --git a/src/Components/test/E2ETest/ServerRenderingTests/MultibyteComponentTypeNameTest.cs b/src/Components/test/E2ETest/ServerRenderingTests/MultibyteComponentTypeNameTest.cs new file mode 100644 index 000000000000..d795826a45ed --- /dev/null +++ b/src/Components/test/E2ETest/ServerRenderingTests/MultibyteComponentTypeNameTest.cs @@ -0,0 +1,38 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Components.TestServer.RazorComponents; +using Microsoft.AspNetCore.Components.E2ETest.Infrastructure; +using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures; +using Microsoft.AspNetCore.E2ETesting; +using OpenQA.Selenium; +using TestServer; +using Xunit.Abstractions; + +namespace Microsoft.AspNetCore.Components.E2ETests.ServerRenderingTests; + +public class MultibyteComponentTypeNameTest : ServerTestBase>> +{ + public MultibyteComponentTypeNameTest( + BrowserFixture browserFixture, + BasicTestAppServerSiteFixture> serverFixture, + ITestOutputHelper output) + : base(browserFixture, serverFixture, output) + { + } + + [Theory] + [InlineData("server")] + [InlineData("webassembly")] + public void CanRenderInteractiveComponentsWithMultibyteName(string renderMode) + { + Navigate($"{ServerPathBase}/multibyte-character-component/{renderMode}"); + + Browser.Equal("True", () => Browser.FindElement(By.ClassName("is-interactive")).Text); + Browser.Equal("0", () => Browser.FindElement(By.ClassName("count")).Text); + + Browser.FindElement(By.ClassName("increment")).Click(); + + Browser.Equal("1", () => Browser.FindElement(By.ClassName("count")).Text); + } +} diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Rendering/PageRenderingComponentWithMultibyteTypeName.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Rendering/PageRenderingComponentWithMultibyteTypeName.razor new file mode 100644 index 000000000000..20745a436cb4 --- /dev/null +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Rendering/PageRenderingComponentWithMultibyteTypeName.razor @@ -0,0 +1,36 @@ +@page "/multibyte-character-component/{renderModeString?}" +@using TestContentPackage + +

Page rendering component with multibyte type name

+ +@if (_renderMode is null) +{ +

+ Warning: Render mode should be specified as a route parameter and have the value 'server' or 'webassembly'. +

+ +

+ Defaulting to a null render mode. +

+} + + + +@code { + private IComponentRenderMode? _renderMode; + + [Parameter] + public string? RenderModeString { get; set; } + + protected override void OnInitialized() + { + if (string.Equals("server", RenderModeString, StringComparison.OrdinalIgnoreCase)) + { + _renderMode = RenderMode.InteractiveServer; + } + else if (string.Equals("webassembly", RenderModeString, StringComparison.OrdinalIgnoreCase)) + { + _renderMode = RenderMode.InteractiveWebAssembly; + } + } +} diff --git "a/src/Components/test/testassets/TestContentPackage/M\303\273ltibyte\303\207haracterCompo\303\261ent.razor" "b/src/Components/test/testassets/TestContentPackage/M\303\273ltibyte\303\207haracterCompo\303\261ent.razor" new file mode 100644 index 000000000000..da1fb1fe99d9 --- /dev/null +++ "b/src/Components/test/testassets/TestContentPackage/M\303\273ltibyte\303\207haracterCompo\303\261ent.razor" @@ -0,0 +1,27 @@ +

+ + Count: @_count +

+ +

+ Is interactive: @_isInteractive +

+ +@code { + private int _count = 0; + private bool _isInteractive; + + private void IncrementCount() + { + _count++; + } + + protected override void OnAfterRender(bool firstRender) + { + if (firstRender) + { + _isInteractive = true; + StateHasChanged(); + } + } +}