fix(rendering): refuse unsupported markup as an error and keep large documents linear #51

Open
james.maes wants to merge 1 commits from fix/renderer-input-bounds into main
pull from: fix/renderer-input-bounds
Owner

Closes #50.

Problem

See #50. Unsupported markup could end the process instead of being reported, some malformed HTML blocks could not be opened, and two parts of the renderer did work that grew with the square of the document.

Change

  • HtmlNesting (Core): a count of open tags kept while tokens arrive (stops at 4,096), and an exact, non-recursive measurement of the parsed tree (limit 256 levels). The count is an upper bound on depth: an end tag closes only the tag on top.
  • MarkdownRenderer parses once with the sanitizer library's own settings (scripting on, CSS-aware context), checks the bound, then sanitizes that tree with SanitizeDom and serializes it with the library's formatter. Output is unchanged.
  • Text ranges are clamped to their block; overlapping ranges are left as authored; replacements are applied in one forward pass.
  • Heading anchors continue from the last suffix used for a repeated title.
  • DocumentOutline takes a CancellationToken, checked between the render, the parse and each heading. Wiring it to window close and newer loads is a later change in the window code.
  • Threat model: nesting is bounded; parse time is not.

User-visible: a document nested deeper than 256 levels is reported as one that cannot be opened.

Verification (run)

  • Local gate script (.NET gates): all pass. Core tests 721 passed, line coverage 100%; fuzz at 5,000 iterations per seed and target passes.
  • Equivalence of the new sanitize path with the library's Sanitize(string): 40,000 seeded mutated inputs, byte-identical output (throwaway test, not committed).
  • New tests render on a thread with a quarter of the default stack, so a recursive walk the bound failed to stop would end the test run.
  • Mutation check: 28 mutants across the bound, the rewrite, the clamp and the anchor loop; 25 killed. Survivors: the start-of-range clamp (no input found that needs it; kept as a guard) and two that only change how many dictionary lookups the anchor loop does.
  • Measured before the change: 100,000 nested elements took 58 s to parse; now refused after 4,097 tokens.

Not run: the FlaUI desktop suite. This change does not touch the WinUI project.

AI assistance

Written with Claude Fable 5.2. An independent read-only review of this diff is recorded below before merge.

🤖 Generated with Claude Code

Closes #50. ## Problem See #50. Unsupported markup could end the process instead of being reported, some malformed HTML blocks could not be opened, and two parts of the renderer did work that grew with the square of the document. ## Change - `HtmlNesting` (Core): a count of open tags kept while tokens arrive (stops at 4,096), and an exact, non-recursive measurement of the parsed tree (limit 256 levels). The count is an upper bound on depth: an end tag closes only the tag on top. - `MarkdownRenderer` parses once with the sanitizer library's own settings (scripting on, CSS-aware context), checks the bound, then sanitizes that tree with `SanitizeDom` and serializes it with the library's formatter. Output is unchanged. - Text ranges are clamped to their block; overlapping ranges are left as authored; replacements are applied in one forward pass. - Heading anchors continue from the last suffix used for a repeated title. - `DocumentOutline` takes a `CancellationToken`, checked between the render, the parse and each heading. Wiring it to window close and newer loads is a later change in the window code. - Threat model: nesting is bounded; parse time is not. User-visible: a document nested deeper than 256 levels is reported as one that cannot be opened. ## Verification (run) - Local gate script (.NET gates): all pass. Core tests 721 passed, line coverage 100%; fuzz at 5,000 iterations per seed and target passes. - Equivalence of the new sanitize path with the library's `Sanitize(string)`: 40,000 seeded mutated inputs, byte-identical output (throwaway test, not committed). - New tests render on a thread with a quarter of the default stack, so a recursive walk the bound failed to stop would end the test run. - Mutation check: 28 mutants across the bound, the rewrite, the clamp and the anchor loop; 25 killed. Survivors: the start-of-range clamp (no input found that needs it; kept as a guard) and two that only change how many dictionary lookups the anchor loop does. - Measured before the change: 100,000 nested elements took 58 s to parse; now refused after 4,097 tokens. Not run: the FlaUI desktop suite. This change does not touch the WinUI project. ## AI assistance Written with Claude Fable 5.2. An independent read-only review of this diff is recorded below before merge. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
james.maes added 1 commit 2026-11-10 23:19:33 +00:00
fix(rendering): refuse unsupported markup as an error and keep large documents linear
node / node (push) Successful in 14s
build / scripts (push) Successful in 14s
windows / windows (push) Successful in 1m57s
build / windows (push) Successful in 1m57s
b095d1a183
Element nesting is bounded before any library walks the parsed tree: a
count kept while tokens arrive stops runaway input early, and an exact
measurement of the parsed tree applies the limit of 256 levels. The
renderer parses once with the sanitizer's own settings and sanitizes that
tree, so the bound is enforced between the parse and the sanitizer's
passes. Deeper markup is an ArgumentException, which the reader already
reports as an unsupported document.

An unterminated script or comment can report a text run past the end of
its block, and malformed markup can report one that starts inside
another; both are handled instead of failing the open. Raw-text
formatting is applied in one forward pass, and repeated heading titles
continue from the last anchor suffix used, so neither grows with the
square of the document. DocumentOutline accepts a cancellation token that
is honoured between its phases.

The threat model states that nesting is bounded and parse time is not.

Closes #50

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
All checks were successful
node / node (push) Successful in 14s
build / scripts (push) Successful in 14s
windows / windows (push) Successful in 1m57s
build / windows (push) Successful in 1m57s
You are not authorized to merge this pull request.
This pull request can be merged automatically.
This branch is out-of-date with the base branch
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin fix/renderer-input-bounds:fix/renderer-input-bounds
git checkout fix/renderer-input-bounds
Sign in to join this conversation.