Repository navigation
[markdown] Require balanced parentheses before bare inline link titles - #2768
Conversation
In `LinkSyntax._parseInlineBareDestinationLink`, return `null` immediately when
whitespace (`$space`, `$lf`, `$cr`, `$ff`) is encountered while inner
parentheses in the bare destination are still open (`parenCount != 1`).
----
### Why this change is necessary
CommonMark 0.31.2 section 6.3 ("Links") specifies that a bare (non-`<>`) inline
link destination may not contain spaces or ASCII control characters, and that
any parentheses inside a bare destination must be balanced.
In `LinkSyntax._parseInlineBareDestinationLink`, `parenCount` starts at `1` for
the opening `(` of `[text](...` and is incremented/decremented for each
unescaped `(` and `)` inside the destination. Previously, when whitespace
(`$space`, `$lf`, `$cr`, `$ff`) was encountered, the loop did *not* check
whether inner destination parentheses were balanced (`parenCount == 1`) before
calling `_parseTitle(parser)`:
- On an input with an unclosed `(` in the destination followed by whitespace and
a closing `)` (with or without a title, such as `[a](b( "t"))` or
`[a](b( )c)`), `_parseTitle(parser)` left `parser.pos` on that `)`,
`parenCount--` decremented `parenCount` from `2` to `1` (`parenCount == 0` was
`false`), and `break` jumped to `parser.advanceBy(1)` at the bottom of the
`while` loop—stepping past `)` and continuing to scan as if still inside the
bare destination. The next `)` then decremented `parenCount` from `1` to `0`
and returned a bogus `InlineLink` (`InlineLink('b(')` or `InlineLink('b(c')`).
- Worse, on repeated inputs like `'[a](b( "t")' * n` or `'[a](b( )' * n` (where
each chunk opens two `(` and decrements `parenCount` only once at the `)`
before `break` steps past it), `parenCount` grew by `+1` per chunk and never
reached `0`, causing the loop to scan across all subsequent chunks to the end
of the line on every `[a](` in O(n^2) time.
### How this change works
When whitespace (`$space`, `$lf`, `$cr`, `$ff`) is encountered in
`_parseInlineBareDestinationLink`:
1. If `parenCount != 1`, at least one inner `(` opened inside the bare
destination has not been closed before the whitespace, so we immediately
return `null`.
2. Otherwise (`parenCount == 1`), all inner parentheses in the destination are
balanced. Once `_parseTitle(parser)` succeeds (or returns `null` with
`parser.charAt(parser.pos) == $rparen` when trailing whitespace precedes the
closing `)`), `parser.pos` is guaranteed to be on the outer closing `)`, so
we return `InlineLink(destination, title: title)` directly instead of
decrementing `parenCount` and breaking back into the loop.
PR HealthPublish ✔️
Documentation at https://github.com/dart-lang/ecosystem/wiki/Publishing-automation. This check can be disabled by tagging the PR with Changelog Entry ✔️
Changes to files need to be accounted for in their respective changelogs. This check can be disabled by tagging the PR with Breaking changes ✔️
This check can be disabled by tagging the PR with API leaks ✔️The following packages contain symbols visible in the public API, but not exported by the library. Export these symbols or remove them from your publicly visible API.
This check can be disabled by tagging the PR with Unused Dependencies ✔️
For details on how to fix these, see dependency_validator. This check can be disabled by tagging the PR with License Headers ✔️
All source files should start with a license header. Unrelated files missing license headers
This check can be disabled by tagging the PR with Coverage ✔️
This check for test coverage is informational (issues shown here will not fail the PR). This check can be disabled by tagging the PR with |
There was a problem hiding this comment.
Code Review
This pull request ensures that bare inline link destinations have balanced parentheses before a title, in accordance with the CommonMark specification. The changes include updating the link syntax parser to check parenthesis balance before parsing the title, updating the changelog, and adding a test case. The feedback suggests also handling horizontal tabs ($tab) as a valid separator to prevent them from being incorrectly treated as part of the bare destination.
Revisions updated by `dart tools/rev_sdk_deps.dart`. core (https://github.com/dart-lang/core/compare/24e09c4..36f57a9): 36f57a99 Tue Nov 3 14:26:17 2026 +0100 Sigurd Meldgaard [os_detect] Remove bin/os_detect.dart (dart-lang/core#1015) 86bacfca Tue Nov 3 05:15:19 2026 -0500 Justin White [characters] Fix RangeError on an unpaired tail surrogate at range start (dart-lang/core#1033) dartdoc (https://github.com/dart-lang/dartdoc/compare/db94437..2f6847b): 2f6847bd Thu Oct 29 11:47:04 2026 +0200 Sergey G. Grekhov Add tests for invalid doc comments placement (dart-lang/dartdoc#4301) 17f2f0a3 Thu Oct 29 10:16:36 2026 +0100 Sarah Zakarias Compare base elements and unify properties in doc-reference-target-differs (dart-lang/dartdoc#4307) dartdoc is held back at 2f6847b, before dart-lang/dartdoc#4312, because its mock SDK version changes conflict in the google3 3-way merge with the hotfix in cl/1005963642. ecosystem (https://github.com/dart-lang/ecosystem/compare/f8faabf..54e1673): 54e1673 Wed Nov 4 09:46:54 2026 +0100 Sigurd Meldgaard fix(firehose): resolve dartExecutable via package:cli_util (dart-lang/ecosystem#469) d754065 Tue Nov 3 10:09:45 2026 -0800 Kevin Moore refactor(corpus): use path.join and seal closed type hierarchies (dart-lang/ecosystem#476) http (https://github.com/dart-lang/http/compare/8c0d66a..1f869d0): 1f869d0 Tue Nov 3 15:51:52 2026 -0800 dependabot[bot] chore(deps): bump the github-actions group with 8 updates (dart-lang/http#2052) 6d7f402 Tue Nov 3 14:12:04 2026 -0800 Brian Quinlan feat(cupertino_http): support custom trust eval (dart-lang/http#2038) 73f8c36 Tue Nov 3 10:15:16 2026 -0800 Brian Quinlan chore(cupertino_http): ready 3.1.1 release (dart-lang/http#2056) native (https://github.com/dart-lang/native/compare/b442038..1838637): 183863758 Wed Nov 4 09:01:27 2026 -0300 Daco Harkes [hooks_runner] Add `PKG_CONFIG_PATH` to environment variables allowlist (dart-lang/native#3779) c9e8118d4 Wed Nov 4 08:16:20 2026 +1100 Liam Appelbe [jni] Fix release mode segfault (dart-lang/native#3777) 5999cf7e4 Wed Nov 4 08:16:16 2026 +1100 Liam Appelbe [objective_c] Prepare to publish (dart-lang/native#3778) d294d9942 Tue Nov 3 15:15:49 2026 +0100 Michael Goderbauer [hooks_runner] Write recorded_uses.json under directory lock in link() (dart-lang/native#3770) 33aa5ef04 Tue Nov 3 11:42:38 2026 +1100 Liam Appelbe [objective_c] Cheaper and safer object/block validity checks (dart-lang/native#3776) protobuf (https://github.com/dart-lang/protobuf/compare/1d52301..19191c8): 19191c8 Wed Nov 4 17:14:33 2026 +0100 Sigurd Meldgaard Use dart.tool.dart2wasm in json.dart so DDC uses json_web.dart (google/protobuf.dart#1108) tools (https://github.com/dart-lang/tools/compare/1579f8d..a342744): a3427446f Wed Nov 4 15:11:35 2026 +0100 Jonas Finnemann Jensen Update CODEOWNERS for pkgs/markdown (dart-lang/tools#2795) 08c9afdc0 Wed Nov 4 14:40:44 2026 +0100 Jonas Finnemann Jensen [markdown] Check URL boundary before matching in AutolinkExtensionSyntax (dart-lang/tools#2786) 0fc5edcae Wed Nov 4 14:35:48 2026 +0100 Jonas Finnemann Jensen [markdown] Skip unclosed inline HTML tags before matching RegExp (dart-lang/tools#2788) b1f6f9956 Wed Nov 4 14:31:55 2026 +0100 Jonas Finnemann Jensen [markdown] Defer shortcut reference link label extraction in LinkSyntax.close (dart-lang/tools#2785) ab135e072 Wed Nov 4 14:30:37 2026 +0100 Jonas Finnemann Jensen [markdown] Avoid quadratic trailing-paren scan in AutolinkExtensionSyntax (dart-lang/tools#2787) f329bf3ea Wed Nov 4 17:09:34 2026 +0530 Ananya Kaul [code_builder] Separate chained cascades after nullable casts (dart-lang/tools#2704) 0a57e3446 Wed Nov 4 05:28:40 2026 +0530 Aman Maurya fix(coverage): allow empty coverage files from skipped test suites (`#2667`) (dart-lang/tools#2668) e95958f6a Tue Nov 3 21:53:57 2026 +0000 Keerti Parthasarathy [unified_analytics] Add root_tool and leaf_tool to every event; key consent on root_tool (dart-lang/tools#2784) 5da06a508 Tue Nov 3 13:11:04 2026 +0100 Jonas Finnemann Jensen [markdown] Reject unescaped '(' in parenthesized inline link titles (dart-lang/tools#2770) f3a5c9277 Tue Nov 3 13:09:32 2026 +0100 Jonas Finnemann Jensen [markdown] Cap parenthesis nesting in bare inline link destinations (dart-lang/tools#2769) 820ce5fb7 Tue Nov 3 13:04:35 2026 +0100 Jonas Finnemann Jensen [markdown] Reject unescaped '(' in parenthesized link reference titles (dart-lang/tools#2771) 775c5d080 Tue Nov 3 13:03:43 2026 +0100 Jonas Finnemann Jensen [markdown] Require balanced parentheses before bare inline link titles (dart-lang/tools#2768) 5ebd52bc5 Tue Nov 3 13:03:08 2026 +0100 Jonas Finnemann Jensen [markdown] Reject unescaped '<' in bracketed inline link destinations (dart-lang/tools#2767) feb7ea928 Tue Nov 3 13:02:15 2026 +0100 Jonas Finnemann Jensen [markdown] Fix 999-character limit in LinkParser.parseLabel (dart-lang/tools#2766) 31888a5da Tue Nov 3 13:01:43 2026 +0100 Jonas Finnemann Jensen [markdown] Enforce 999-character limit in _parseReferenceLinkLabel (dart-lang/tools#2765) e016c5d70 Tue Nov 3 09:26:32 2026 +0100 dart-ecosystem-bot[bot] [boring] Pin the prebuilt binaries of boring-binaries-20261101-f869fdca423c (dart-lang/tools#2778) 8c7935573 Mon Nov 2 10:15:24 2026 -0800 Kevin Moore [html] Move WPT selector fixtures to third_party/wpt-selectors (dart-lang/tools#2772) web (https://github.com/dart-lang/web/compare/45166fd..f388bf5): f388bf5 Sun Nov 1 06:57:22 2026 +0000 dependabot[bot] Bump the github-actions group with 4 updates (dart-lang/web#611) 658ce2f Sat Oct 31 12:47:20 2026 +0100 Moritz Migrate to ecosystem health publish check and setup-dart publish workflow (dart-lang/web#610) Change-Id: Ifcae02c4712b4b27a5a8ab1c57d8dcf13a6700cf Reviewed-on: https://dart-review.googlesource.com/c/sdk/+/574281 Reviewed-by: Brian Quinlan <[email protected]> Commit-Queue: Nate Bosch <[email protected]>
In
LinkSyntax._parseInlineBareDestinationLink, returnnullimmediately whenwhitespace (
$space,$lf,$cr,$ff) is encountered while innerparentheses in the bare destination are still open (
parenCount != 1).Why this change is necessary
CommonMark 0.31.2 section 6.3 ("Links") specifies that a bare (non-
<>) inlinelink destination may not contain spaces or ASCII control characters, and that
any parentheses inside a bare destination must be balanced.
In
LinkSyntax._parseInlineBareDestinationLink,parenCountstarts at1forthe opening
(of[text](...and is incremented/decremented for eachunescaped
(and)inside the destination. Previously, when whitespace(
$space,$lf,$cr,$ff) was encountered, the loop did not checkwhether inner destination parentheses were balanced (
parenCount == 1) beforecalling
_parseTitle(parser):(in the destination followed by whitespace anda closing
)(with or without a title, such as[a](b( "t"))or[a](b( )c)),_parseTitle(parser)leftparser.poson that),parenCount--decrementedparenCountfrom2to1(parenCount == 0wasfalse), andbreakjumped toparser.advanceBy(1)at the bottom of thewhileloop—stepping past)and continuing to scan as if still inside thebare destination. The next
)then decrementedparenCountfrom1to0and returned a bogus
InlineLink(InlineLink('b(')orInlineLink('b(c')).'[a](b( "t")' * nor'[a](b( )' * n(whereeach chunk opens two
(and decrementsparenCountonly once at the)before
breaksteps past it),parenCountgrew by+1per chunk and neverreached
0, causing the loop to scan across all subsequent chunks to the endof the line on every
[a](in O(n^2) time.How this change works
When whitespace (
$space,$lf,$cr,$ff) is encountered in_parseInlineBareDestinationLink:parenCount != 1, at least one inner(opened inside the baredestination has not been closed before the whitespace, so we immediately
return
null.parenCount == 1), all inner parentheses in the destination arebalanced. Once
_parseTitle(parser)succeeds (or returnsnullwithparser.charAt(parser.pos) == $rparenwhen trailing whitespace precedes theclosing
)),parser.posis guaranteed to be on the outer closing), sowe return
InlineLink(destination, title: title)directly instead ofdecrementing
parenCountand breaking back into the loop.Behavior change
When a bare inline link destination is followed by whitespace and a title, the
destination must have balanced parentheses. CommonMark §6.3 allows parentheses
in a bare destination "only if (a) they are backslash-escaped or (b) they are
part of a balanced pair of unescaped parentheses". Before,
[a](b( "t"))produced a link to
b(with the title dropped; now it is literal text, as incmarkandcommonmark.js.Differential testing
Rendered every input with
origin/mainand with this branch (GFM extensionset, 30 s per-file timeout) and compared the HTML. Sources:
mdn/content,github/docs,kubernetes/website,freeCodeCamp,tldr,flutter/website,dart-lang/site-www,microsoft/vscode-docs,withastro/docs,rust-lang/book, GitHub READMEs, and Wikipediacmark,cmark-gfm,commonmark.js,pulldown-cmark,md4c,goldmark,markdown-it,comrak,marked,pandoc, and pathological generatorscmark,md4c, andmarkdown-it-pyfuzzersOSS-Fuzz inputs that sit at the 30 s timeout or the stack limit flip between
runs; every one reported as a new failure was re-run standalone on both trees.
Where the HTML changed, the changed region was also compared with the output of
commonmark.js0.31 for the same input.Results:
[Cobalt Strike and Tradecraft]([https://hausec.com/…](https://hausec.com/… "…"))(the whole
[url](url "title")pasted as the destination).mainlinkedto the garbage href
%5Bhttps://hausec.com/…%5D(https://hausec.com/…; nowthe text
[Cobalt Strike and Tradecraft](is followed by the inner link,byte-for-byte what
commonmark.jsrenders for this document.commonmark.jswhere the old output did not; for 1 it is the other wayaround; the rest differ from
commonmark.jsbefore and after for unrelatedreasons (9 of them take
commonmark.jsitself more than 10 s).suites.
mainand 3.1 s here, but two of them (both 192 KB of go from 5.0 s onmainto 18 s on this branch. They are the GHSA bracket/delimiter-stack quadratic that is still open onmain: before, many of the malformed[…](…candidates were accepted as links and consumed; now they are correctly rejected, so every bracket stays on theList-backed delimiter stack and the existing O(n) scans in_linkOrImagerun more often. The full [markdown] WIP: Fix quadratic, exponential, and deep-recursion #2737 series (linked-list delimiter stack) parses both inputs in ~0.1 s. For contrast, the input this change is for,'[a](b( "t")' * 8000(88 KB), takes 82.7 s onmainand 70 ms here.1 timeout resolved. Cumulative parse time +0.1% (noise).