Skip to content

fix(ui): harden markdown rendering - #92

Merged
sanity merged 7 commits into
mainfrom
fix/harden-markdown
Oct 1, 2026
Merged

sanity merged 7 commits into
mainfrom
fix/harden-markdown

Conversation

@sanity

@sanity sanity commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

Problem

Page content is written by other people and rendered as markdown in the page view and the editor preview. The renderer should cope with malformed or unusually large input without stalling or failing.

Approach

  • Patched markdown build. [patch.crates-io] pins freenet/markdown-rs branch freenet/panic-fixes at c0646ed: the parser fixes River already uses (4dcf957), plus one commit that indexes the parser's edit map so that block-level parsing is linear rather than quadratic in document length. That commit was checked against the crate's test suite and by a differential run of generated inputs (identical to_html and to_mdast output before and after).
  • One bounded render path (ui/src/components/markdown_render.rs), used by both the page view and the editor preview. Text past any limit is shown as escaped plain text, with line breaks kept. It is the only module that calls the markdown crate.
  • Page links are resolved within the same bounds. The size limit is checked before [[page links]] are resolved; resolving stops at the last closing ]], its output and work are capped, and title lookups go through an index.
  • No recursion over markdown trees. The only tree this code builds (for the exact reference count) is walked and dropped iteratively.

Limits

Calibrated against the real markdown files over 2 KiB across the Freenet and mediator repos (about 1,000 files).

Limit Value Why
Source size 128 KiB 32x River's 4 KiB. The largest real file is under 60 KiB.
Block containers opened on one line 16 Same as River.
Block container markers in the whole document 4,096 The most any real file uses is about 700. Very long lists and checklists fall back to plain text for now.
Containers that may be open on a line (markers plus indentation, carried through blank and lazy lines) 48 Nesting can also be built across lines. The deepest real file reaches 30.
Sum over all lines, blank ones included, of the containers that may be open 32 Ki The parser checks every open container on every line. The largest real file reaches about 9,000.
Sum over runs of non-blank lines of (run bytes)^2 (12 KiB)^2 Inline parsing is quadratic within a paragraph, and a paragraph never crosses a blank line. The largest real document uses 70% of it.
Sum over runs of blank lines of (lines in the run)^2 64 Ki Inside code and HTML blocks a run of empty lines costs the parser time quadratic in its length. Real files rarely have two blank lines in a row.
Table cells (delimiter-row columns times rows) 64 Ki Body rows are padded to the header width.
]: count times ] count 1 Mi
Reference expansion 4 MiB A cheap textual bound first; if that is too high, an exact count from the syntax tree, allowed only for text within half the size and block limits.
HTML output 4 MiB Checked after parsing and again after heading ids and link attributes are added.
Resolved page-link text 128 KiB, and 4 work units per byte of that Same as the source limit.

A leading byte order mark is skipped when measuring, as the parser skips it; any other one counts as text. Footnote labels are read the way the parser reads them.

The plain-text fallback is one pre-wrapped text block rather than an element per line, and the page view reuses its last render while the content, page titles and link settings are unchanged, so re-renders triggered by unrelated updates do not repeat the work.

The slowest inputs found within the limits render in about 0.25 s natively (release build).

UI-only change: markdown is a dependency of delta-ui alone, so the site contract and delegate WASM are unchanged. Nothing needs migrating.

Testing

cargo test -p delta-ui --bins -- markdown_render page_links:

  • River's parser regression inputs go through both HTML rendering and the syntax-tree path. Reverting any one of the six parser-fix commits in the fork makes a test fail (checked by building against the fork with each commit reverted).
  • Each limit is tested at its exact boundary with small test limits, so every input is tiny. Counters (container markers, block cost, table cells, reference lookups, tree nodes visited, bytes searched by link resolution) are asserted directly, including that they grow linearly.
  • The exact reference count, its half-limit gate, and both HTML caps have their own tests.
  • The page view and editor preview are tested to render through the bounded module, and no other source file may call the markdown crate.
  • Cargo.lock is checked to pin the patched build: the last fork commit changes no output, so this pin is what keeps it.

The full workspace suite, clippy and the wasm check are clean locally.

[AI-assisted - Claude]

https://claude.ai/code/session_013fuenPkF3T7ZkeypDFSRmx

@sanity sanity changed the title fix(ui): harden markdown rendering against malformed input fix(ui): harden markdown rendering Oct 1, 2026
- Pin the `markdown` dependency to a patched build ([patch.crates-io],
  freenet/markdown-rs at a fixed rev), which also parses long documents in
  linear rather than quadratic time.
- Render page content and the editor preview through one bounded path that
  shows text past its limits (size, nesting per line, block length, table
  size, reference count and expansion, HTML size) as escaped plain text.
- Walk and drop markdown trees without recursion.

Claude-Session: https://claude.ai/code/session_013fuenPkF3T7ZkeypDFSRmx
- Skip only a single leading byte order mark when measuring, as the
  parser does; any other U+FEFF counts as ordinary text.
- Add a whole-document limit on block container markers, alongside the
  per-line one.
- Check the page size before resolving [[page links]], cap the resolved
  output and the work spent on it, stop scanning at the last closing
  bracket pair, and look titles up through an index.
- Make the limits a struct so tests reach each one with tiny inputs, and
  assert the counters against them: exact boundaries, linear scaling,
  the exact reference count and its half-limit gate, both HTML caps.
- Run the parser regression inputs through the syntax-tree path too,
  test that the page view and editor preview render through the bounded
  module, and pin the patched markdown build in Cargo.lock.

[AI-assisted - Claude]

Claude-Session: https://claude.ai/code/session_013fuenPkF3T7ZkeypDFSRmx
…ike the parser

- Estimate the containers that may be open on each line (markers plus
  indentation, carried through blank and lazy lines) and limit both the
  deepest line and the sum over all lines.
- End a footnote label at its first unescaped `]`.
- Show the plain-text fallback as one pre-wrapped text block instead of
  an element per line.
- Keep the last page render and reuse it while its inputs are unchanged.

[AI-assisted - Claude]

Claude-Session: https://claude.ai/code/session_013fuenPkF3T7ZkeypDFSRmx
Inside code and HTML blocks the parser's time grows with the square of
a run of empty lines, so charge each run its length squared.

[AI-assisted - Claude]

Claude-Session: https://claude.ai/code/session_013fuenPkF3T7ZkeypDFSRmx
@sanity
sanity force-pushed the fix/harden-markdown branch from 96de75c to c9e3ec8 Compare October 1, 2026 03:03
@sanity

sanity commented Oct 1, 2026

Copy link
Copy Markdown
Contributor Author

Consolidated review

Tier: Full (renders content written by other people; availability-sensitive).

Lenses run

Round 1, at bcc6ead

  • Independent Claude reviewers, including a testing lens and a review of the patched markdown fork.
  • Codex (codex review --base origin/main): the first attempt failed on a usage limit. The retry completed with one finding, folded in below.
  • Gemini (gateway review): unavailable, HTTP 402 (no credits). Claude lenses substituted for it.

Round 2, at bef6feb

  • Two blind Claude reviewers, a security lens and a testing lens.
  • The security lens ran two more confirmation passes, at 499c203 and 96de75c.
  • Neither external model was re-run in this round.

Findings and resolution

Round 1, four blocking findings, all fixed:

  1. Byte-order-mark accounting now matches the parser: only a single leading mark is skipped, and any other is counted as text. This was also the Codex finding.
  2. A whole-document limit on block container markers.
  3. Page-link resolution is size-checked first, its output is capped, and it stops scanning at the last closing ]].
  4. New tests:
    • River's parser regression inputs, run through both HTML rendering and the syntax-tree path;
    • checks that the page view and editor preview render through the bounded module;
    • the exact reference count and its half-limit gate;
    • both HTML caps.

Round 2, security lens:

  • Blocking, fixed:
    • Nesting built across lines was not counted. There are now limits on the containers that may be open on a line, and on their sum over all lines.
    • Footnote labels are now read the way the parser reads them.
    • Runs of blank lines are now limited.
  • Should-fix, fixed:
    • The page view reuses its last render while its inputs are unchanged.
    • The plain-text fallback is now a single text block.
  • Nits, not addressed here:
    • Raw-HTML broken-link markup is escaped by the renderer. This behaviour predates this PR.
    • Heading ids carry no prefix. This also predates this PR and is worth a separate issue.
  • The final pass found nothing blocking. Within the limits, 40 further input shapes render in 48 to 360 ms natively, with linear scaling.

Round 2, testing lens (no blocking findings):

  • Fixed:
    • The work-budget boundary is now tested exactly.
    • The crate-use check now also catches renamed use imports.
  • Not changed:
    • The link-resolution counter is reported by the code it measures. The alternative suggested was timing, and timing-based tests are out of scope for this repo's tests. The counter does catch a reintroduced rescan, which was checked by mutation.

Verification

  • Every guard was checked by mutation: each limit, cap, early exit, the BOM handling and the wiring checks were removed or bypassed in turn, and a test failed each time.
  • Reverting any one of the six parser-fix commits in the fork also makes a test fail. For two of them, only the syntax-tree path catches it.
  • cargo fmt, clippy, the workspace tests and the wasm check are clean. CI is green.

[AI-assisted - Claude]

@sanity
sanity merged commit 258b192 into main Oct 1, 2026
4 checks passed
@sanity
sanity deleted the fix/harden-markdown branch October 1, 2026 03:06
sanity added a commit that referenced this pull request Oct 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant