diff --git a/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/Components/ComponentMarkupEncodingPassTest.cs b/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/Components/ComponentMarkupEncodingPassTest.cs index 7aafc14cfe3..891454248fb 100644 --- a/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/Components/ComponentMarkupEncodingPassTest.cs +++ b/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/Components/ComponentMarkupEncodingPassTest.cs @@ -173,6 +173,25 @@ public void Execute_MixedHtmlContent_MultipleHTMLEntities_DoesNotSetEncoded() Assert.False(node.HasEncodedContent); } + [Fact] + public void Execute_MixedHtmlContent_HexadecimalHTMLEntities_DoesNotSetEncoded() + { + // Arrange + var document = CreateDocument(@" +
Symbols AB== @DateTime.Now
"); + var expected = NormalizeContent("Symbols AB== "); + + var documentNode = Lower(document); + + // Act + Pass.Execute(document, documentNode); + + // Assert + var node = documentNode.FindDescendantNodes().Single(); + Assert.Equal(expected, GetHtmlContent(node)); + Assert.False(node.HasEncodedContent); + } + private string NormalizeContent(string content) { // Normalize newlines since we are testing lengths of things. diff --git a/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/Legacy/ParserHelpersTest.cs b/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/Legacy/ParserHelpersTest.cs new file mode 100644 index 00000000000..3363a8f1929 --- /dev/null +++ b/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/Legacy/ParserHelpersTest.cs @@ -0,0 +1,378 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using Xunit; + +namespace Microsoft.AspNetCore.Razor.Language.Legacy; + +public class ParserHelpersTest +{ + [Theory] + [InlineData("&", "&")] + [InlineData("<", "<")] + [InlineData(">", ">")] + [InlineData(""", "\"")] + [InlineData("'", "'")] + [InlineData(" ", "\u00A0")] + [InlineData("©", "\u00A9")] + [InlineData("®", "\u00AE")] + [InlineData("™", "\u2122")] + [InlineData("…", "\u2026")] + [InlineData("—", "\u2014")] + [InlineData("–", "\u2013")] + [InlineData("«", "\u00AB")] + [InlineData("»", "\u00BB")] + [InlineData("Á", "\u00C1")] + [InlineData("á", "\u00E1")] + [InlineData("Α", "\u0391")] + [InlineData("α", "\u03B1")] + public void TryGetHtmlEntity_ValidNamedEntities_ReturnsTrue(string input, string expectedReplacement) + { + // Arrange + var content = input.AsMemory(); + + // Act + var result = ParserHelpers.TryGetHtmlEntity(content, out var entity, out var replacement); + + // Assert + Assert.True(result); + Assert.Equal(input, entity.ToString()); + Assert.Equal(expectedReplacement, replacement); + } + + [Theory] + [InlineData("A", "A")] // Basic decimal + [InlineData("a", "a")] // Basic decimal + [InlineData(" ", " ")] // Space character + [InlineData("©", "\u00A9")] // Copyright symbol + [InlineData("€", "\u20AC")] // Euro symbol + [InlineData("￿", "\uFFFF")] // Max BMP character + [InlineData("™", "\u2122")] // Trademark symbol + public void TryGetHtmlEntity_ValidDecimalEntities_ReturnsTrue(string input, string expectedReplacement) + { + // Arrange + var content = input.AsMemory(); + + // Act + var result = ParserHelpers.TryGetHtmlEntity(content, out var entity, out var replacement); + + // Assert + Assert.True(result); + Assert.Equal(input, entity.ToString()); + Assert.Equal(expectedReplacement, replacement); + } + + [Theory] + [InlineData("A", "A")] // Basic hex lowercase + [InlineData("A", "A")] // Basic hex uppercase + [InlineData("a", "a")] // Basic hex lowercase + [InlineData("a", "a")] // Basic hex uppercase + [InlineData(" ", " ")] // Space character + [InlineData("©", "\u00A9")] // Copyright symbol + [InlineData("©", "\u00A9")] // Copyright symbol uppercase + [InlineData("€", "\u20AC")] // Euro symbol + [InlineData("￿", "\uFFFF")] // Max BMP character + public void TryGetHtmlEntity_ValidHexEntities_ReturnsTrue(string input, string expectedReplacement) + { + // Arrange + var content = input.AsMemory(); + + // Act + var result = ParserHelpers.TryGetHtmlEntity(content, out var entity, out var replacement); + + // Assert + Assert.True(result); + Assert.Equal(input, entity.ToString()); + Assert.Equal(expectedReplacement, replacement); + } + + [Theory] + [InlineData("�x41;", "A")] // 0x prefix lowercase + [InlineData("�X41;", "A")] // 0x prefix uppercase + [InlineData("�x61;", "a")] // 0x prefix lowercase + [InlineData("�X61;", "a")] // 0x prefix uppercase + [InlineData("�x20;", " ")] // Space character + [InlineData("�xa9;", "\u00A9")] // Copyright symbol + [InlineData("�XA9;", "\u00A9")] // Copyright symbol uppercase + public void TryGetHtmlEntity_InvalidHexEntitiesWithZeroPrefix_ReturnsTrue(string input, string expectedReplacement) + { + // Arrange + var content = input.AsMemory(); + + // Act + var result = ParserHelpers.TryGetHtmlEntity(content, out var entity, out var replacement); + + // Assert + Assert.True(result); + Assert.Equal(input, entity.ToString()); + Assert.Equal(expectedReplacement, replacement); + } + + [Theory] + [InlineData("&")] // Just ampersand + [InlineData("&")] // No semicolon + [InlineData("&invalid;")] // Invalid named entity + [InlineData("¬valid;")] // Invalid named entity + [InlineData("&xyz123;")] // Invalid named entity + [InlineData("&#;")] // Empty numeric entity + [InlineData("&#x;")] // Empty hex entity + [InlineData("�x;")] // Empty 0x entity + [InlineData("&#abc;")] // Invalid decimal digits + [InlineData("&#xghi;")] // Invalid hex digits + [InlineData("�xghi;")] // Invalid 0x hex digits + [InlineData("& extra")] // No semicolon with extra content + [InlineData("&am p;")] // Space in entity name + public void TryGetHtmlEntity_InvalidEntities_ReturnsFalse(string input) + { + // Arrange + var content = input.AsMemory(); + + // Act + var result = ParserHelpers.TryGetHtmlEntity(content, out var entity, out var replacement); + + // Assert + Assert.False(result); + Assert.True(entity.IsEmpty); + Assert.Null(replacement); + } + + [Theory] + [InlineData("�")] // Null character (below valid range) + [InlineData("")] // Control character (below valid range) + [InlineData("�")] // Surrogate range start (0xD800) + [InlineData("�")] // Surrogate range end (0xDFFF) + [InlineData("𐀀")] // Above BMP (0x10000) + [InlineData("􏿿")] // Max Unicode (0x10FFFF) + [InlineData("�")] // Above max Unicode + [InlineData("�")] // Hex null character + [InlineData("�")] // Hex surrogate start + [InlineData("�")] // Hex surrogate end + [InlineData("𐀀")] // Hex above BMP + public void TryGetHtmlEntity_OutOfRangeNumericEntities_ReturnsFalse(string input) + { + // Arrange + var content = input.AsMemory(); + + // Act + var result = ParserHelpers.TryGetHtmlEntity(content, out var entity, out var replacement); + + // Assert + Assert.False(result); + Assert.True(entity.IsEmpty); + Assert.Null(replacement); + } + + [Fact] + public void TryGetHtmlEntity_EmptyInput_ReturnsFalse() + { + // Arrange + var content = ReadOnlyMemory.Empty; + + // Act + var result = ParserHelpers.TryGetHtmlEntity(content, out var entity, out var replacement); + + // Assert + Assert.False(result); + Assert.True(entity.IsEmpty); + Assert.Null(replacement); + } + + [Theory] + [InlineData("a")] // Doesn't start with & + [InlineData("hello")] // Regular text + [InlineData("123")] // Numbers + [InlineData("")] // Empty string + public void TryGetHtmlEntity_NotStartingWithAmpersand_ReturnsFalse(string input) + { + // Arrange + var content = input.AsMemory(); + + // Act + var result = ParserHelpers.TryGetHtmlEntity(content, out var entity, out var replacement); + + // Assert + Assert.False(result); + Assert.True(entity.IsEmpty); + Assert.Null(replacement); + } + + [Theory] + [InlineData("&extra", "&", "&")] + [InlineData("<more", "<", "<")] + [InlineData("Atext", "A", "A")] + [InlineData("Amore", "A", "A")] + [InlineData("©right", "©", "\u00A9")] + public void TryGetHtmlEntity_EntityWithExtraContent_ParsesOnlyEntity(string input, string expectedEntity, string expectedReplacement) + { + // Arrange + var content = input.AsMemory(); + + // Act + var result = ParserHelpers.TryGetHtmlEntity(content, out var entity, out var replacement); + + // Assert + Assert.True(result); + Assert.Equal(expectedEntity, entity.ToString()); + Assert.Equal(expectedReplacement, replacement); + } + + [Theory] + [InlineData("&")] // Valid entity name without semicolon + [InlineData("<")] // Valid entity name without semicolon + [InlineData(">")] // Valid entity name without semicolon + [InlineData("©")] // Valid entity name without semicolon + [InlineData("A")] // Valid numeric without semicolon + [InlineData("A")] // Valid hex without semicolon + public void TryGetHtmlEntity_ValidEntityWithoutSemicolon_ReturnsFalse(string input) + { + // Arrange + var content = input.AsMemory(); + + // Act + var result = ParserHelpers.TryGetHtmlEntity(content, out var entity, out var replacement); + + // Assert + Assert.False(result); + Assert.True(entity.IsEmpty); + Assert.Null(replacement); + } + + [Theory] + [InlineData("& ")] // Entity followed by space + [InlineData("&<")] // Multiple entities + [InlineData("&text")] // Entity followed by text + public void TryGetHtmlEntity_EntityFollowedByOtherCharacters_StopsAtSemicolon(string input) + { + // Arrange + var content = input.AsMemory(); + + // Act + var result = ParserHelpers.TryGetHtmlEntity(content, out var entity, out var replacement); + + // Assert + Assert.True(result); + Assert.Equal("&", entity.ToString()); + Assert.Equal("&", replacement); + } + + [Theory] + [InlineData("&a;")] // Single character entity (invalid) + [InlineData("&ab;")] // Two character entity (invalid) + [InlineData("&123;")] // Numeric named entity (invalid) + [InlineData("&a1b;")] // Mixed alphanumeric (invalid) + public void TryGetHtmlEntity_ShortInvalidNamedEntities_ReturnsFalse(string input) + { + // Arrange + var content = input.AsMemory(); + + // Act + var result = ParserHelpers.TryGetHtmlEntity(content, out var entity, out var replacement); + + // Assert + Assert.False(result); + Assert.True(entity.IsEmpty); + Assert.Null(replacement); + } + + [Theory] + [InlineData("&#-1;")] // Negative number + [InlineData("&#x-1;")] // Negative hex + [InlineData("�x-1;")] // Negative 0x hex + [InlineData(".5;")] // Decimal point + [InlineData(".5;")] // Decimal point in hex + public void TryGetHtmlEntity_InvalidNumericFormats_ReturnsFalse(string input) + { + // Arrange + var content = input.AsMemory(); + + // Act + var result = ParserHelpers.TryGetHtmlEntity(content, out var entity, out var replacement); + + // Assert + Assert.False(result); + Assert.True(entity.IsEmpty); + Assert.Null(replacement); + } + + [Fact] + public void TryGetHtmlEntity_LongValidEntity_ReturnsTrue() + { + // Arrange + var input = "∳"; // One of the longer entity names + var content = input.AsMemory(); + + // Act + var result = ParserHelpers.TryGetHtmlEntity(content, out var entity, out var replacement); + + // Assert + Assert.True(result); + Assert.Equal(input, entity.ToString()); + Assert.Equal("\u2233", replacement); + } + + [Theory] + // Decimal boundary tests + [InlineData("!", "!")] // Boundary: 0x21 (valid) + [InlineData(" ", " ")] // Boundary: 0x20 (valid - minimum valid) + [InlineData("")] // Boundary: 0x1F (invalid - just below minimum) + [InlineData("퟿", "\uD7FF")] // Boundary: 0xD7FF (valid, just before surrogate range) + [InlineData("�")] // Boundary: 0xD800 (invalid - surrogate range start) + [InlineData("�")] // Boundary: 0xDFFF (invalid - surrogate range end) + [InlineData("", "\uE000")] // Boundary: 0xE000 (valid, just after surrogate range) + [InlineData("￿", "\uFFFF")] // Boundary: 0xFFFF (valid - maximum valid) + [InlineData("𐀀")] // Boundary: 0x10000 (invalid - just above maximum) + // Hexadecimal boundary tests + [InlineData("!", "!")] // Boundary: 0x21 (valid) + [InlineData(" ", " ")] // Boundary: 0x20 (valid - minimum valid) + [InlineData("")] // Boundary: 0x1F (invalid - just below minimum) + [InlineData("퟿", "\uD7FF")] // Boundary: 0xD7FF (valid, just before surrogate range) + [InlineData("�")] // Boundary: 0xD800 (invalid - surrogate range start) + [InlineData("�")] // Boundary: 0xDFFF (invalid - surrogate range end) + [InlineData("", "\uE000")] // Boundary: 0xE000 (valid, just after surrogate range) + [InlineData("￿", "\uFFFF")] // Boundary: 0xFFFF (valid - maximum valid) + [InlineData("𐀀")] // Boundary: 0x10000 (invalid - just above maximum) + public void TryGetHtmlEntity_BoundaryValues_HandledCorrectly(string input, string? expectedReplacement = null) + { + // Arrange + var content = input.AsMemory(); + var shouldSucceed = expectedReplacement != null; + + // Act + var result = ParserHelpers.TryGetHtmlEntity(content, out var entity, out var replacement); + + // Assert + if (shouldSucceed) + { + Assert.True(result); + Assert.Equal(input, entity.ToString()); + Assert.Equal(expectedReplacement, replacement); + } + else + { + Assert.False(result); + Assert.True(entity.IsEmpty); + Assert.Null(replacement); + } + } + + [Theory] + [InlineData("&", 5)] // Full entity + [InlineData("&extra", 5)] // Entity with extra content + [InlineData("A", 5)] // Numeric entity + [InlineData("A", 6)] // Hex entity + [InlineData("�x41;", 7)] // 0x hex entity + public void TryGetHtmlEntity_ReturnsCorrectEntityLength(string input, int expectedEntityLength) + { + // Arrange + var content = input.AsMemory(); + + // Act + var result = ParserHelpers.TryGetHtmlEntity(content, out var entity, out var replacement); + + // Assert + Assert.True(result); + Assert.Equal(expectedEntityLength, entity.Length); + Assert.NotNull(replacement); + } +} diff --git a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/Components/ComponentMarkupEncodingPass.cs b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/Components/ComponentMarkupEncodingPass.cs index 84cb08fd29c..af80e16ace4 100644 --- a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/Components/ComponentMarkupEncodingPass.cs +++ b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/Components/ComponentMarkupEncodingPass.cs @@ -1,24 +1,20 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -#nullable disable - using System; -using System.Collections.Generic; -using System.Linq; +using System.Collections.Frozen; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; using Microsoft.AspNetCore.Razor.Language.Intermediate; using Microsoft.AspNetCore.Razor.Language.Legacy; +using Microsoft.AspNetCore.Razor.PooledObjects; namespace Microsoft.AspNetCore.Razor.Language.Components; -internal class ComponentMarkupEncodingPass : ComponentIntermediateNodePassBase, IRazorOptimizationPass +internal sealed class ComponentMarkupEncodingPass(RazorLanguageVersion version) : ComponentIntermediateNodePassBase, IRazorOptimizationPass { - private readonly RazorLanguageVersion _version; - - public ComponentMarkupEncodingPass(RazorLanguageVersion version) - { - _version = version; - } + private readonly RazorLanguageVersion _version = version; // Runs after ComponentMarkupBlockPass public override int Order => ComponentMarkupDiagnosticPass.DefaultOrder + 20; @@ -40,41 +36,47 @@ protected override void ExecuteCore(RazorCodeDocument codeDocument, DocumentInte rewriter.Visit(documentNode); } - private class Rewriter : IntermediateNodeWalker + private sealed class Rewriter(RazorLanguageVersion version) : IntermediateNodeWalker { - // Markup content in components are rendered in one of the following two ways, - // AddContent - we encode it when used with prerendering and inserted into the DOM in a safe way (low perf impact) - // AddMarkupContent - renders the content directly as markup (high perf impact) + // Markup content in components are rendered in one of the following two ways: + // + // 1. AddContent - we encode it when used with pre-rendering and inserted into the DOM in a safe way (low perf impact) + // 2. AddMarkupContent - renders the content directly as markup (high perf impact) + // // Because of this, we want to use AddContent as much as possible. // - // We want to use AddMarkupContent to avoid aggressive encoding during prerendering. - // Specifically, when one of the following characters are in the content, + // We want to use AddMarkupContent to avoid aggressive encoding during pre-rendering. + // Specifically, when one of the following characters are in the content: + // // 1. New lines (\r, \n), tabs (\t), angle brackets (<, >) - so they get rendered as actual new lines, tabs, brackets instead of // 2. Any character outside the ASCII range - private static readonly char[] EncodedCharacters = new[] { '\r', '\n', '\t', '<', '>' }; + private static readonly FrozenSet EncodedCharacters = ['\r', '\n', '\t', '<', '>']; - private readonly bool _avoidEncodingScripts; - private readonly Dictionary _seenEntities = new Dictionary(StringComparer.Ordinal); + private readonly bool _avoidEncodingScripts = version >= RazorLanguageVersion.Version_8_0; private bool _avoidEncodingContent; - public Rewriter(RazorLanguageVersion version) - { - _avoidEncodingScripts = version >= RazorLanguageVersion.Version_8_0; - } - public override void VisitMarkupElement(MarkupElementIntermediateNode node) { // We don't want to HTML-encode literal content inside