diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 61e9edf3..2d27f5b2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,7 +4,6 @@ on: [push, pull_request] env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} DOCS_PATH: ${{ secrets.DOCS_PATH }} DOCS_BRANCH: ${{ secrets.DOCS_BRANCH }} @@ -57,10 +56,13 @@ jobs: 10.0.x - name: Build - run: ./build.sh -AngleSharpVersion 1.5.0 + run: ./build.sh -AngleSharpVersion 1.9.0 windows: runs-on: windows-latest + permissions: + contents: write # create the GitHub release + id-token: write # request the OIDC token for NuGet trusted publishing steps: - uses: actions/checkout@v5 @@ -71,12 +73,23 @@ jobs: dotnet-version: | 10.0.x + # Exchanges the GitHub OIDC token for a short-lived (1 hour) NuGet API key. + # Requires a trusted publishing policy on nuget.org for this repository and workflow file. + - name: NuGet login + id: nuget_login + if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/devel') + uses: NuGet/login@v1 + with: + user: ${{ secrets.NUGET_USER }} + - name: Build + env: + NUGET_API_KEY: ${{ steps.nuget_login.outputs.NUGET_API_KEY }} run: | if ($env:GITHUB_REF -eq "refs/heads/main") { - .\build.ps1 -Target Publish -AngleSharpVersion 1.5.0 + .\build.ps1 -Target Publish -AngleSharpVersion 1.9.0 } elseif ($env:GITHUB_REF -eq "refs/heads/devel") { - .\build.ps1 -Target PrePublish -AngleSharpVersion 1.5.0 + .\build.ps1 -Target PrePublish -AngleSharpVersion 1.9.0 } else { - .\build.ps1 -AngleSharpVersion 1.5.0 + .\build.ps1 -AngleSharpVersion 1.9.0 } diff --git a/AGENTS.md b/AGENTS.md index 05a4df7d..c14cc5d5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,7 +35,7 @@ dotnet run --project src/AngleSharp.Performance.Css/AngleSharp.Performance.Css.c Notes: -- CI invokes `./build.sh -AngleSharpVersion 1.5.0` on Linux and `./build.ps1` on Windows. +- CI invokes `./build.sh -AngleSharpVersion 1.9.0` on Linux and `./build.ps1` on Windows. - Treat warnings as errors is enabled in `src/Directory.Build.props`. ## 3) Build and target framework facts @@ -43,6 +43,8 @@ Notes: - Library project: `src/AngleSharp.Css/AngleSharp.Css.csproj` - Targets: `netstandard2.0;net8.0;net10.0` - Additional Windows-only targets: `net462;net472` + - The AngleSharp dependency defaults to version `1.9.0` and allows versions below `2.0.0`. + - The `net8.0` and `net10.0` targets set `IsAotCompatible`, so trim and AOT analyzer warnings fail the build. - Test project: `src/AngleSharp.Css.Tests/AngleSharp.Css.Tests.csproj` - Targets: `net8.0` - Fallout entrypoint: `build/Build.cs` @@ -137,7 +139,9 @@ dotnet test src/AngleSharp.Css.Tests/AngleSharp.Css.Tests.csproj - Avoid broad refactors when making focused feature fixes. - Keep public API changes intentional; many files under `Dom` are effectively surface area. - Existing code mixes nullable contexts (`#nullable enable` and `#nullable disable`); do not normalize unrelated files. +- Stay trim- and AOT-clean. The library sets `IsAotCompatible` on its .NETCoreApp targets, so the trim and AOT analyzers run on every build and a warning breaks it. Reflection over a runtime type (`GetType().GetProperties()`, `Assembly.GetTypes()`) is what they reject: read members off an annotated generic parameter instead (`Css` via AngleSharp's `ToDictionary`), or use a fixed table. The analyzers only see the library itself; publishing a small `PublishAot` console app against it is the end-to-end check. - In `src/AngleSharp.Css/ValueConverters.cs`, avoid static field initializers that reference converters declared later in the same file. Forward references can capture `null` during type initialization and only fail at runtime. +- Untrusted input can nest arbitrarily deep, and a stack overflow ends the process. `CssBuilder` parses nested blocks recursively and carries a depth that stops at `CssParserOptions.MaxNestingDepth`; a new recursive parsing path needs the same. The limit is a mitigation, not a fix: the recursion is still there, so a raised limit can still overflow the stack. `src/AngleSharp.Css.Tests/Parsing/DeepNesting.cs` holds the tests, which parse on a 256 KB stack. ## 8) Documentation and onboarding references diff --git a/CHANGELOG.md b/CHANGELOG.md index d92adffc..45647e7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,13 @@ +# 1.3.1 + +Released on Sunday, November 1 2026. + +- Improved the parser with a nesting limit for rules (`MaxNestingDepth`) (#258) @michaelthyregod +- Updated to use a minimum of AngleSharp 1.9 (#257) @michaelthyregod +- Added `Render(element, parent)` to the window, rendering the subtree of an element again below its parent's render node from an earlier render tree (e.g. after its inline style changed) +- Added trimming and Native AOT compatibility (#257) @michaelthyregod +- Added a trim-safe generic `Css` overload; the `Object` overload is obsolete (#257) @michaelthyregod + # 1.3.0 Released on Wednesday, October 28 2026. diff --git a/build/Build.cs b/build/Build.cs index 63369a93..ea050d45 100644 --- a/build/Build.cs +++ b/build/Build.cs @@ -197,9 +197,9 @@ protected override void OnBuildInitialized() .DependsOn(RunUnitTests) .Executes(() => { + // In CI this is the short-lived key issued by NuGet trusted publishing (NuGet/login). var apiKey = Environment.GetEnvironmentVariable("NUGET_API_KEY"); - if (apiKey.IsNullOrEmpty()) { throw new BuildAbortedException("Could not resolve the NuGet API key."); diff --git a/src/AngleSharp.Css.Docs/package.json b/src/AngleSharp.Css.Docs/package.json index 8bb0eefa..da5e46a7 100644 --- a/src/AngleSharp.Css.Docs/package.json +++ b/src/AngleSharp.Css.Docs/package.json @@ -1,6 +1,6 @@ { "name": "@anglesharp/css", - "version": "1.3.0", + "version": "1.3.1", "preview": true, "description": "The doclet for the AngleSharp.Css documentation.", "keywords": [ diff --git a/src/AngleSharp.Css.Tests/Extensions/ApiExtension.cs b/src/AngleSharp.Css.Tests/Extensions/ApiExtension.cs index 673c38b0..c9409b2a 100644 --- a/src/AngleSharp.Css.Tests/Extensions/ApiExtension.cs +++ b/src/AngleSharp.Css.Tests/Extensions/ApiExtension.cs @@ -1,7 +1,10 @@ namespace AngleSharp.Css.Tests.Extensions { + using AngleSharp.Css.Dom; using AngleSharp.Dom; using NUnit.Framework; + using System; + using System.Collections.Generic; using System.Linq; using static CssConstructionFunctions; @@ -23,5 +26,42 @@ public void ExtensionCssWithEmptyListOnly() var elements = document.QuerySelectorAll("li").Css("color", "red"); Assert.AreEqual(0, elements.Count()); } + + [Test] + public void ExtensionCssWithAnonymousObjectSetsDeclarations() + { + var document = ParseDocument(""); + var elements = document.QuerySelectorAll("li").Css(new { color = "red", display = "none" }); + Assert.AreEqual(2, elements.Count()); + + foreach (var element in elements) + { + Assert.AreEqual("rgba(255, 0, 0, 1)", element.GetStyle().GetPropertyValue("color")); + Assert.AreEqual("none", element.GetStyle().GetPropertyValue("display")); + } + } + + [Test] + public void ExtensionCssWithDictionaryCopiesItsEntries() + { + // A dictionary binds to the generic overload ahead of the key value pair one, so it + // must be copied there rather than have the properties of Dictionary read. + var document = ParseDocument(""); + var properties = new Dictionary { ["color"] = "red", ["margin-left"] = "2px" }; + var element = document.QuerySelectorAll("li").Css(properties).Single(); + Assert.AreEqual("rgba(255, 0, 0, 1)", element.GetStyle().GetPropertyValue("color")); + Assert.AreEqual("2px", element.GetStyle().GetPropertyValue("margin-left")); + } + + [Test] + public void ExtensionCssWithValueDeclaredAsObjectStillWorks() + { + var document = ParseDocument("
  • a
"); + Object properties = new { color = "red" }; +#pragma warning disable CS0618 // The obsolete Object overload is the one under test. + var element = document.QuerySelectorAll("li").Css(properties).Single(); +#pragma warning restore CS0618 + Assert.AreEqual("rgba(255, 0, 0, 1)", element.GetStyle().GetPropertyValue("color")); + } } } diff --git a/src/AngleSharp.Css.Tests/Extensions/PseudoElementRenderTests.cs b/src/AngleSharp.Css.Tests/Extensions/PseudoElementRenderTests.cs index 88461b22..e213ec60 100644 --- a/src/AngleSharp.Css.Tests/Extensions/PseudoElementRenderTests.cs +++ b/src/AngleSharp.Css.Tests/Extensions/PseudoElementRenderTests.cs @@ -47,6 +47,25 @@ private static ElementRenderNode FindElement(ElementRenderNode node, string id) return null; } + [Test] + public void ElementRenderedAgainBelowItsParentTakesItsNewStyleAndInherits() + { + var document = ParseDocument("" + + "
text
"); + var window = document.DefaultView; + var root = (ElementRenderNode)window.Render(new PlainRenderDevice()); + var parent = FindElement(root, "parent"); + var target = document.GetElementById("target"); + target.SetAttribute("style", "display: block"); + + var node = window.Render(target, parent, new PlainRenderDevice()); + + Assert.AreSame(parent, node.Parent); + Assert.AreEqual("block", node.ComputedStyle.GetDisplay()); + Assert.AreEqual("rgba(255, 0, 0, 1)", FindElement(node, "child").ComputedStyle.GetColor()); + Assert.IsInstanceOf(node.Children.OfType().First().Ref); + } + [Test] public void BeforePseudoElementIsTheFirstChildWithItsGeneratedTextContent() { diff --git a/src/AngleSharp.Css.Tests/Parsing/DeepNesting.cs b/src/AngleSharp.Css.Tests/Parsing/DeepNesting.cs new file mode 100644 index 00000000..6c412b9e --- /dev/null +++ b/src/AngleSharp.Css.Tests/Parsing/DeepNesting.cs @@ -0,0 +1,172 @@ +#nullable disable +namespace AngleSharp.Css.Tests.Parsing +{ + using AngleSharp.Css.Dom; + using AngleSharp.Css.Dom.Events; + using AngleSharp.Css.Parser; + using NUnit.Framework; + using System; + using System.Collections.Generic; + using System.Linq; + using System.Runtime.ExceptionServices; + using System.Text; + using System.Threading; + using static CssConstructionFunctions; + + /// + /// Deeply nested input used to overflow the stack, which ends the process. The deep cases + /// use depths that crashed before. The limits only mitigate this: parsing still recurses + /// once per level, so these tests parse on a thread with a small, fixed stack to show the + /// defaults hold there too. + /// + [TestFixture] + public class DeepNesting + { + private const Int32 Deep = 10000; + + // as AngleSharp's own selector nesting tests do: less stack than the 1 MB main thread + private const Int32 SmallStack = 256 * 1024; + + private static T OnSmallStack(Func parse) + { + var result = default(T); + var error = default(Exception); + var thread = new Thread(() => + { + try + { + result = parse(); + } + catch (Exception ex) + { + error = ex; + } + }, SmallStack); + thread.Start(); + thread.Join(); + + if (error is not null) + { + ExceptionDispatchInfo.Capture(error).Throw(); + } + + return result; + } + + private static CssStyleSheet ParseOnSmallStack(String source) => + OnSmallStack(() => ParseStyleSheet(source)); + + private static String Repeat(String value, Int32 count) => + new StringBuilder(value.Length * count).Insert(0, value, count).ToString(); + + private static String NestedGroups(String atRule, Int32 depth, String inner) => + Repeat(atRule + "{", depth) + inner + Repeat("}", depth); + + /// + /// The number of grouping rules nested in each other, following the first rule of each. + /// + private static Int32 GroupDepth(ICssRuleList rules, out ICssRuleList innermost) + { + var depth = 0; + innermost = rules; + + while (innermost.Length > 0 && innermost[0] is ICssGroupingRule group) + { + depth++; + innermost = group.Rules; + } + + return depth; + } + + private static List CollectErrors(String source, CssParserOptions options) + { + var codes = new List(); + var parser = new CssParser(options); + parser.Error += (_, ev) => codes.Add(((CssErrorEvent)ev).Code); + parser.ParseStyleSheet(source); + return codes; + } + + [TestCase("@media all")] + [TestCase("@supports (display: block)")] + [TestCase("@layer x")] + [TestCase("@container (width > 1px)")] + [TestCase("@scope (a)")] + [TestCase("@starting-style")] + public void DeeplyNestedGroupingRulesStopAtTheDefaultLimit(String atRule) + { + var sheet = ParseOnSmallStack(NestedGroups(atRule, Deep, "a{color:red}") + "b{color:blue}"); + var depth = GroupDepth(sheet.Rules, out _); + Assert.AreEqual(CssParserOptions.DefaultMaxNestingDepth + 1, depth); + Assert.AreEqual(2, sheet.Rules.Length); + Assert.AreEqual("b", ((ICssStyleRule)sheet.Rules[1]).SelectorText); + } + + [Test] + public void StyleRuleNestedInAsManyGroupsAsAllowedIsKept() + { + var limit = CssParserOptions.DefaultMaxNestingDepth; + var sheet = ParseOnSmallStack(NestedGroups("@media all", limit, "a{color:red}")); + Assert.AreEqual(limit, GroupDepth(sheet.Rules, out var innermost)); + Assert.AreEqual(1, innermost.Length); + Assert.AreEqual("a", ((ICssStyleRule)innermost[0]).SelectorText); + } + + [Test] + public void StyleRuleNestedDeeperThanAllowedIsDroppedWithAnError() + { + var limit = CssParserOptions.DefaultMaxNestingDepth; + var source = NestedGroups("@media all", limit + 1, "a{color:red}"); + var sheet = ParseOnSmallStack(source); + Assert.AreEqual(limit + 1, GroupDepth(sheet.Rules, out var innermost)); + Assert.AreEqual(0, innermost.Length); + CollectionAssert.Contains(CollectErrors(source, default), (Int32)CssParseError.NestingTooDeep); + } + + [Test] + public void NestingLimitIsConfigurable() + { + var options = new CssParserOptions { MaxNestingDepth = 3 }; + var source = NestedGroups("@media all", 5, "a{color:red}") + "b{color:blue}"; + var sheet = ParseStyleSheet(source, options); + Assert.AreEqual(4, GroupDepth(sheet.Rules, out var innermost)); + Assert.AreEqual(0, innermost.Length); + Assert.AreEqual(2, sheet.Rules.Length); + Assert.AreEqual(1, CollectErrors(source, options).Count(code => code == (Int32)CssParseError.NestingTooDeep)); + } + + [Test] + public void NestingLimitAboveTheDefaultIsHonored() + { + // on the 1 MB test thread: a raised limit is the caller's risk, and 200 nested @media + // already overflow a 256 KB stack + var options = new CssParserOptions { MaxNestingDepth = 200 }; + var sheet = ParseStyleSheet(NestedGroups("@media all", 200, "a{color:red}"), options); + Assert.AreEqual(200, GroupDepth(sheet.Rules, out var innermost)); + Assert.AreEqual(1, innermost.Length); + } + + [TestCase("& b")] + [TestCase(".b")] + public void DeeplyNestedStyleRulesDoNotOverflow(String selector) + { + var sheet = ParseOnSmallStack("a{" + Repeat(selector + "{", Deep) + "color:red" + Repeat("}", Deep + 1) + "c{color:blue}"); + Assert.AreEqual(2, sheet.Rules.Length); + Assert.AreEqual("c", ((ICssStyleRule)sheet.Rules[1]).SelectorText); + } + + [Test] + public void NestedStyleRuleBeyondTheLimitIsDroppedAndTheDeclarationsContinue() + { + var options = new CssParserOptions { MaxNestingDepth = 1 }; + var sheet = ParseStyleSheet("a { & b { & c { color: red } color: green } color: blue }", options); + var a = (ICssStyleRule)sheet.Rules[0]; + Assert.AreEqual("blue", a.Style.GetPropertyValue("color").Replace("rgba(0, 0, 255, 1)", "blue")); + Assert.AreEqual(1, a.Rules.Length); + var b = (ICssStyleRule)a.Rules[0]; + Assert.AreEqual("rgba(0, 128, 0, 1)", b.Style.GetPropertyValue("color")); + Assert.AreEqual(0, b.Rules.Length); + } + } +} diff --git a/src/AngleSharp.Css/AngleSharp.Css.csproj b/src/AngleSharp.Css/AngleSharp.Css.csproj index 6ef9f33e..be708955 100644 --- a/src/AngleSharp.Css/AngleSharp.Css.csproj +++ b/src/AngleSharp.Css/AngleSharp.Css.csproj @@ -8,6 +8,25 @@ enable + + + true + + + + + + + https://anglesharp.github.io MIT @@ -21,7 +40,7 @@ true true snupkg - 1.5.0 + 1.9.0 diff --git a/src/AngleSharp.Css/Common/DynamicallyAccessedMembersAttribute.cs b/src/AngleSharp.Css/Common/DynamicallyAccessedMembersAttribute.cs new file mode 100644 index 00000000..ca421f0f --- /dev/null +++ b/src/AngleSharp.Css/Common/DynamicallyAccessedMembersAttribute.cs @@ -0,0 +1,43 @@ +#if !NET5_0_OR_GREATER +namespace System.Diagnostics.CodeAnalysis +{ + // The trimmer matches these by name, so this internal copy lets the generic parameters of the + // reflection-based extensions carry their annotation on the targets whose BCL predates it. + + [AttributeUsage( + AttributeTargets.Field | AttributeTargets.ReturnValue | AttributeTargets.GenericParameter | + AttributeTargets.Parameter | AttributeTargets.Property | AttributeTargets.Method | + AttributeTargets.Class | AttributeTargets.Interface | AttributeTargets.Struct, + Inherited = false)] + internal sealed class DynamicallyAccessedMembersAttribute : Attribute + { + public DynamicallyAccessedMembersAttribute(DynamicallyAccessedMemberTypes memberTypes) + { + MemberTypes = memberTypes; + } + + public DynamicallyAccessedMemberTypes MemberTypes { get; } + } + + [Flags] + internal enum DynamicallyAccessedMemberTypes + { + None = 0, + PublicParameterlessConstructor = 0x0001, + PublicConstructors = 0x0002 | PublicParameterlessConstructor, + NonPublicConstructors = 0x0004, + PublicMethods = 0x0008, + NonPublicMethods = 0x0010, + PublicFields = 0x0020, + NonPublicFields = 0x0040, + PublicNestedTypes = 0x0080, + NonPublicNestedTypes = 0x0100, + PublicProperties = 0x0200, + NonPublicProperties = 0x0400, + PublicEvents = 0x0800, + NonPublicEvents = 0x1000, + Interfaces = 0x2000, + All = ~None, + } +} +#endif diff --git a/src/AngleSharp.Css/Common/RequiresUnreferencedCodeAttribute.cs b/src/AngleSharp.Css/Common/RequiresUnreferencedCodeAttribute.cs new file mode 100644 index 00000000..fdc4c1da --- /dev/null +++ b/src/AngleSharp.Css/Common/RequiresUnreferencedCodeAttribute.cs @@ -0,0 +1,20 @@ +#if !NET5_0_OR_GREATER +namespace System.Diagnostics.CodeAnalysis +{ + // The trimmer matches this by name, so this internal copy lets the obsolete Object overloads + // carry their warning on the targets whose BCL predates it. + + [AttributeUsage(AttributeTargets.Method | AttributeTargets.Constructor | AttributeTargets.Class, Inherited = false)] + internal sealed class RequiresUnreferencedCodeAttribute : Attribute + { + public RequiresUnreferencedCodeAttribute(String message) + { + Message = message; + } + + public String Message { get; } + + public String? Url { get; set; } + } +} +#endif diff --git a/src/AngleSharp.Css/Extensions/CssApiExtensions.cs b/src/AngleSharp.Css/Extensions/CssApiExtensions.cs index a07aab2d..ab3f69a3 100644 --- a/src/AngleSharp.Css/Extensions/CssApiExtensions.cs +++ b/src/AngleSharp.Css/Extensions/CssApiExtensions.cs @@ -7,6 +7,7 @@ namespace AngleSharp.Dom using AngleSharp.Html.Dom; using System; using System.Collections.Generic; + using System.Diagnostics.CodeAnalysis; using System.Linq; /// @@ -96,17 +97,46 @@ public static T Css(this T elements, IEnumerable /// declarations. /// /// The type of element collection. + /// The declared type whose properties are the declarations. /// The collection. /// /// An enumeration of properties in form of an anonymous object, that /// carries key-value pairs. /// /// The collection itself. + public static T Css(this T elements, TProperties properties) + where T : class, IEnumerable + { + // Typed as the interface so this binds to the key value pair overload, not back to itself. + IEnumerable> realProperties = properties.ToDictionary(); + return elements.Css(realProperties); + } + + /// + /// Extends the CSS of the given elements with the specified + /// declarations. + /// + /// The type of element collection. + /// The collection. + /// + /// An enumeration of properties in form of an anonymous object, that + /// carries key-value pairs. + /// + /// The collection itself. + [Obsolete(RuntimePropertiesObsolete)] + [RequiresUnreferencedCode(RuntimePropertiesTrimming)] public static T Css(this T elements, Object properties) where T : class, IEnumerable { - var realProperties = properties.ToDictionary(); + // Kept so code compiled against it keeps working; a recompile binds everything but a + // value declared as Object to the generic overload. Being obsolete itself, it may call + // the obsolete Object overload of ToDictionary without a warning. + IEnumerable> realProperties = properties.ToDictionary(); return elements.Css(realProperties); } + + private const String RuntimePropertiesObsolete = "Reads the properties of the value's runtime type, which trimming can remove. Pass the value with its own type, such as an anonymous object, or as a dictionary, to use the generic overload."; + + private const String RuntimePropertiesTrimming = "Reads the public properties of the value's runtime type, which trimming can remove."; } } diff --git a/src/AngleSharp.Css/Extensions/WindowExtensions.cs b/src/AngleSharp.Css/Extensions/WindowExtensions.cs index b74ffbb4..0433e11f 100644 --- a/src/AngleSharp.Css/Extensions/WindowExtensions.cs +++ b/src/AngleSharp.Css/Extensions/WindowExtensions.cs @@ -170,6 +170,25 @@ public static IRenderNode Render(this IWindow window, IRenderDevice? renderDevic return builder.RenderDocument(renderDevice); } + /// + /// Renders the element and its subtree again, below the render node of its parent from an + /// earlier render tree, which stays as it is. This is for a change confined to the subtree + /// (e.g. the inline style of the element), where rendering the whole document again would + /// give the same nodes for everything else. + /// + /// In case no render device is supplied the context's default render device is chosen. + /// + /// The window to extend. + /// The element to render. + /// The render node of the element's parent. + /// The device for rendering, if any. + /// The created render node, whose parent is the given one. + public static ElementRenderNode Render(this IWindow window, IElement element, ElementRenderNode parent, IRenderDevice? renderDevice = null) + { + var builder = RenderTreeBuilder.GetInstance(window); + return builder.RenderElement(element, parent, renderDevice ?? builder.DefaultDevice); + } + private static IRenderDevice GetRenderDevice(this IWindow window) => window.Document.Context.GetService() ?? new DefaultRenderDevice(); } diff --git a/src/AngleSharp.Css/Parser/CssBuilder.cs b/src/AngleSharp.Css/Parser/CssBuilder.cs index 4336d2ce..d412913d 100644 --- a/src/AngleSharp.Css/Parser/CssBuilder.cs +++ b/src/AngleSharp.Css/Parser/CssBuilder.cs @@ -47,6 +47,12 @@ sealed class CssBuilder private readonly IBrowsingContext _context; private CssRuleList _commentRuleTarget; + // The blocks that hold rules are parsed recursively, one set of frames per level, so the + // depth is capped (CssParserOptions.MaxNestingDepth) to keep a hostile style sheet from + // overflowing the stack. Counted: grouping rules and style rules (nested style rules). + private readonly Int32 _maxDepth; + private Int32 _depth; + #endregion #region ctor @@ -56,6 +62,7 @@ public CssBuilder(CssParserOptions options, CssTokenizer tokenizer, IBrowsingCon _tokenizer = tokenizer; _options = options; _context = context; + _maxDepth = options.MaxNestingDepth > 0 ? options.MaxNestingDepth : CssParserOptions.DefaultMaxNestingDepth; } #endregion @@ -64,6 +71,13 @@ public CssBuilder(CssParserOptions options, CssTokenizer tokenizer, IBrowsingCon public ICssRule CreateRule(ICssStyleSheet sheet, CssToken token) { + if (_depth > _maxDepth) + { + RaiseErrorOccurred(CssParseError.NestingTooDeep, token.Position); + JumpToRuleEnd(ref token); + return null; + } + switch (token.Type) { case CssTokenType.AtKeyword: @@ -535,7 +549,10 @@ public CssStyleRule CreateStyle(CssStyleRule rule, CssToken current) return null; } + // the declarations may hold nested style rules + _depth++; FillDeclarations(rule.Owner, rule.Style, NextToken()); + _depth--; return rule; } @@ -557,6 +574,15 @@ private CssStyleRule CreateNestedStyle(CssStyleRule rule, ref CssToken current) return null; } + if (_depth > _maxDepth) + { + // drop the rule with its block; the declarations continue after it + RaiseErrorOccurred(CssParseError.NestingTooDeep, position); + SkipBlock(); + current = NextToken(); + return null; + } + rule.SelectorText = selectorText; if (rule.Selector is null && _options.IsToleratingInvalidSelectors) @@ -564,7 +590,9 @@ private CssStyleRule CreateNestedStyle(CssStyleRule rule, ref CssToken current) rule.SetInvalidSelector(selectorText); } + _depth++; FillDeclarations(rule.Owner, rule.Style, NextToken()); + _depth--; return rule; } @@ -682,6 +710,7 @@ private Boolean FillRules(CssGroupingRule group) { var previousRuleTarget = _commentRuleTarget; _commentRuleTarget = group.Rules as CssRuleList; + _depth++; var token = NextToken(); CollectTrivia(group.Owner, ref token); @@ -698,6 +727,7 @@ private Boolean FillRules(CssGroupingRule group) CollectTrivia(group.Owner, ref token); } + _depth--; _commentRuleTarget = previousRuleTarget; return token.Type == CssTokenType.CurlyBracketClose; @@ -867,6 +897,34 @@ private void JumpToEnd(ref CssToken current) } } + /// + /// Skips the tokens of a block whose opening curly bracket was just read, up to and + /// including its closing one, without parsing what it holds. + /// + private void SkipBlock() + { + var curly = 1; + + do + { + var token = NextToken(); + + switch (token.Type) + { + case CssTokenType.CurlyBracketOpen: + curly++; + break; + case CssTokenType.CurlyBracketClose: + curly--; + break; + case CssTokenType.EndOfFile: + curly = 0; + break; + } + } + while (curly != 0); + } + private void JumpToRuleEnd(ref CssToken current) { var scopes = 0; diff --git a/src/AngleSharp.Css/Parser/CssParseError.cs b/src/AngleSharp.Css/Parser/CssParseError.cs index e76311e5..3d7f8abd 100644 --- a/src/AngleSharp.Css/Parser/CssParseError.cs +++ b/src/AngleSharp.Css/Parser/CssParseError.cs @@ -48,6 +48,11 @@ public enum CssParseError : byte [DomDescription("This position does not support a linebreak (LF, FF).")] LineBreakUnexpected = 0x16, /// + /// The rule is nested deeper than the parser allows. + /// + [DomDescription("The rule is nested deeper than the parser allows.")] + NestingTooDeep = 0x17, + /// /// The name of the @-rule is unknown. /// [DomDescription("The name of the @-rule is unknown.")] diff --git a/src/AngleSharp.Css/Parser/CssParserOptions.cs b/src/AngleSharp.Css/Parser/CssParserOptions.cs index 713b2992..d81f3ff5 100644 --- a/src/AngleSharp.Css/Parser/CssParserOptions.cs +++ b/src/AngleSharp.Css/Parser/CssParserOptions.cs @@ -7,6 +7,32 @@ namespace AngleSharp.Css.Parser /// public struct CssParserOptions { + /// + /// The number of blocks a rule may be nested in when is + /// not set: 64. + /// + public const Int32 DefaultMaxNestingDepth = 64; + + /// + /// Gets or sets the number of blocks a rule may be nested in, counting grouping rules + /// such as @media, @supports, @layer or @container, and style rules holding nested + /// style rules. A rule nested deeper is dropped like an invalid rule, raising + /// ; parsing continues after it. 0, the + /// default, uses . + /// + /// This is a mitigation, not a fix: rules are still parsed recursively, once per level, + /// and only this limit keeps a hostile style sheet from overflowing the stack, which + /// ends the process. Raising it brings that crash back: without a limit, about 580 + /// nested @media rules overflow a 1 MB stack and about 125 a 256 KB thread stack (.NET 8, + /// before the JIT optimizes the code). + /// + /// + public Int32 MaxNestingDepth + { + get; + set; + } + /// /// Gets or sets if unknown declarations (CSS properties) should be included. /// If they are not included unknown property names will be ignored. diff --git a/src/AngleSharp.Css/RenderTree/RenderTreeBuilder.cs b/src/AngleSharp.Css/RenderTree/RenderTreeBuilder.cs index d4e7e550..775dcf12 100644 --- a/src/AngleSharp.Css/RenderTree/RenderTreeBuilder.cs +++ b/src/AngleSharp.Css/RenderTree/RenderTreeBuilder.cs @@ -70,6 +70,12 @@ public ElementRenderNode RenderElement(IElement element, IRenderDevice device) return RenderElement(element, collection, null, parentSpecifiedStyle, parentComputedStyle); } + public ElementRenderNode RenderElement(IElement element, ElementRenderNode parent, IRenderDevice device) + { + var collection = CreateStyleCollection(device); + return RenderElement(element, collection, parent, parent.SpecifiedStyle, parent.ComputedStyle); + } + private StyleCollection CreateStyleCollection(IRenderDevice device) { var document = _window.Document; diff --git a/src/AngleSharp.Performance.Css/DeepNestingBenchmarks.cs b/src/AngleSharp.Performance.Css/DeepNestingBenchmarks.cs new file mode 100644 index 00000000..e4b2b4ba --- /dev/null +++ b/src/AngleSharp.Performance.Css/DeepNestingBenchmarks.cs @@ -0,0 +1,44 @@ +namespace AngleSharp.Performance.Css +{ + using AngleSharp.Css.Parser; + using BenchmarkDotNet.Attributes; + using System; + using System.Text; + + /// + /// Parsing style sheets that nest deeply. The shapes stay within the default nesting limit + /// (64) and within what a parser without the limit survives (on a 1 MB stack, before the + /// JIT optimizes, about 580 nested @media rules), so builds with and without the limit do + /// the same work. + /// + [MemoryDiagnoser] + public class DeepStyleSheetBenchmarks + { + private static readonly CssParser Parser = new CssParser(); + + private String _source = null!; + + [Params("media-10", "media-60", "style-10", "style-60")] + public String Shape { get; set; } = null!; + + [GlobalSetup] + public void Setup() + { + var dash = Shape.LastIndexOf('-'); + var depth = Int32.Parse(Shape.Substring(dash + 1)); + + _source = Shape.Substring(0, dash) switch + { + "media" => Repeat("@media screen{", depth) + "a{color:red}" + Repeat("}", depth), + "style" => "a{" + Repeat("& b{", depth) + "color:red" + Repeat("}", depth + 1), + _ => throw new ArgumentException(Shape), + }; + } + + [Benchmark] + public Int32 Parse() => Parser.ParseStyleSheet(_source).Rules.Length; + + private static String Repeat(String value, Int32 count) => + new StringBuilder(value.Length * count).Insert(0, value, count).ToString(); + } +} diff --git a/src/Directory.Build.props b/src/Directory.Build.props index b105dbaf..be3a0c07 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -2,7 +2,7 @@ Extends the CSSOM from the core AngleSharp library. AngleSharp.Css - 1.3.0 + 1.3.1 enable latest true