Skip to content

markdown: hand react(), render() and ansi() the resolved href and title through SpanDetail accessors - #46069

Open
robobun wants to merge 3 commits into
mainfrom
robobun/bcd0a2d5/md-resolve-link-attrs
Open

robobun wants to merge 3 commits into
mainfrom
robobun/bcd0a2d5/md-resolve-link-attrs

Conversation

@robobun

@robobun robobun commented Oct 19, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #31936

Problem

Fix

  • SpanDetail::href() and title() return the value that html() writes before it encodes. href_as_written() and title_as_written() are the named opt-out for HtmlRenderer and bun fmt.
  • react(), render(), ansi() and the image prefetch call them. A value without a \ or & is lent with no scan: the scan that cuts it notes them.
  • Verified: test/js/bun/md/ and test/cli/run/markdown-entrypoint.test.ts. 39 new tests fail without the fix.
  • Self-reviewed: 14 concerns raised, 13 addressed. Not taken: a whole-tree comparison with html() (Notes).

Background

  • In a destination and a title, \* means * and & means & (CommonMark 6.3).
  • SpanDetail is what the parser gives a renderer for a link or an image. Its fields are private now.
  • Considered: a resolve call in MarkdownObject.rs only. It leaves ansi() and the prefetch raw.

Downsides

  • Code that unescapes href, src or title itself, or adds mailto: itself, now does it twice.
  • A character reference is resolved before user code sees the value: [x](javascript:alert(1)) gives href: "javascript:alert(1)".
  • A resolved value costs about 1,000 instructions and one realloc. No link without an escape got slower. .text grows 3,840 bytes.
Notes

Where it came from. A fuzzing report that a maintainer relayed. Its question: "Could both resolve escapes and entities before they hand the values on?" No issue exists for that half. #31936 is the user report for the scheme.

Values, before and after (react() props and render() meta, html() is unchanged)

input field before after
[a](<https://example.com/Foo_\(bar\)> "R&amp;D \"n\"") href https://example.com/Foo_\(bar\) https://example.com/Foo_(bar)
title R&amp;D \"n\" R&D "n"
[a](/?a=1&amp;b=2&#38;c) href /?a=1&amp;b=2&#38;c /?a=1&b=2&c
![logo](a\_b.png) src a\_b.png a_b.png
[a][r] with [r]: </a\_b> 'x &copy; y' href, title /a\_b, x &copy; y /a_b, x © y
<a@b.co>, or a@b.co with autolinks: true href a@b.co mailto:a@b.co
www.example.com with autolinks: true href www.example.com http://www.example.com
[a](a\b&nosuch;) href a\b&nosuch; no change
<https://example.com/a\_b> href https://example.com/a\_b no change
[text](a@b.co) href a@b.co no change

ansi() changes in the same way: the OSC 8 target, the (url) fallback, the image source and title. bun file.md downloads a remote image from the resolved URL.

Design

  • SpanDetail has six readers: HtmlRenderer, AnsiRenderer, ImageUrlCollector, the two renderers in MarkdownObject.rs, and the tree builder of bun fmt. Two of them want the source bytes. So the contract is on SpanDetail: private fields, the resolved value under the short name, a named accessor for the source bytes.
  • helpers::resolve_escapes has the rules of the writers in html_renderer.rs. Those writers stay as they are: they resolve and encode in one pass with no allocation.
  • The flag that lets the accessors skip the scan comes from the loops that cut the value: scan_link_destination and the two title loops. A reference definition keeps it in RefDef. An earlier draft took it from the marks of the whole block. A link callback can write into the input Buffer, which the parser reads in place, so that flag could be stale.
  • In ansi_renderer.rs the values are resolved before the control bytes are stripped, because &#27; stands for one. From the address of a link or an image only the control bytes go. What follows an ESC stays, because it is part of the address, as in html(). What is written into an OSC 8 sequence also loses a tab and a line break (&#9;, &#10;), which would break the sequence. The file lookup of an image keeps them: a file name can have a tab. The prefetch and the renderer make the key of a remote image with the same function.
  • ![](..\..\img\logo.png) has worked in ansi() on every platform, because the path join takes \ as a separator. With the escapes resolved that path is ....\img\logo.png. So ansi() tries the resolved path first and the path as written second. If both a_b.png and a/_b.png exist, ![](a\_b.png) now shows a_b.png.

Related pull requests

Numbers (release builds of the merge base 91e7e3b and of this branch, x64 with AVX-512, BUN_JSC_useJIT=0, documents of 1,000 elements, the run with 0 iterations subtracted, instructions per element)

  • html() and render({}): -126 and -126 per inline link, -37 and -37 per reference link, +0.4 per e-mail autolink in html(), -0.5 and -0.3 on a document without links (instructions:u, median of 5 runs of 2,000 iterations, run-to-run spread 1 to 2 per element). html() with <...> destinations: -32.
  • react()/render()/ansi(): -110 / -108 / -200 per clean inline link, -23 / -19 / -110 per reference link, -110 / -104 / -200 per inline link in a block with a stray ampersand (500 iterations).
  • react()/render()/ansi(): +24 / +32 / -194 per e-mail autolink. ansi() had the scheme before.
  • react()/render()/ansi(): +1,965 / +1,993 / +481 per link with an escape in the destination and a character reference in the title. That is two resolved values in react() and render(), one in ansi().
  • pre-scans per attribute: 0 on inline links and on reference links without a \ or &, 1 on an attribute of a link that holds one (calls of resolve_escapes, hardware breakpoint counts).
  • allocations: +0 on the documents without an escape in every mode (text, inline links, reference links, links beside an ampersand, e-mail autolinks). +1 allocator call per resolved href or title: a realloc in react() and render(), a malloc in ansi() (hardware breakpoint counts on the mimalloc entry points).
  • release binary: .text +3,840 bytes (size), the stripped file +4,096 bytes. SpanDetail stays 40 bytes (a const assertion). RefDef grows from 32 to 40 bytes.
  • bun file.md with a local and a remote image in a Kitty terminal: file syscalls 20 -> 20, network syscalls 13 -> 13. The path in newfstatat changes from a/_b.png to a_b.png, and the request from /r.png?a=1&amp;b=2 to /r.png?a=1&b=2. A local image whose resolved path does not exist costs one more newfstatat.
  • The gain on links without an escape is a side effect of the change in the destination scan.
  • Tools: valgrind, strace and bloaty are not installed on the machine, and it has no package mirror. The instructions are instructions:u from perf_event_open. The call counts are hardware breakpoints (PERF_TYPE_BREAKPOINT). The syscalls are counted with ptrace.

Tests

  • md-react.test.ts, md-render-callback.test.ts: a table of inputs for each API, as a string and as a Buffer. The scheme rows of markdown: add the mailto: and http:// autolink scheme to react() and render() hrefs #42757. A link callback that writes an escape into a later destination.
  • md-edge-cases.test.ts: a sweep compares the href, src and title of react() with the attributes that html() writes, on spec.txt, spec-gfm.txt, spec-permissive-autolinks.txt, regressions.txt and coverage.txt: 204 values in 176 of 763 examples. html() also percent-encodes, so the sweep applies a model of that encoding to the react() value first. Examples with a raw <a> or <img> are left out. A second table has inputs that the spec files lack, with the values written out. The existing test that folds the react() tree into the render() output ties render() to the same values.
  • md-edge-cases.test.ts, ansi(): resolved values, and character references that stand for ESC, BEL, CSI, a tab and a line feed.
  • markdown-entrypoint.test.ts: local images by resolved path and by the path as written, one with a tab in its name. A remote image with &amp; in the URL is requested with & and shown.
  • Not taken from the self-review: render the whole react() tree and compare it with html(). It needs an HTML writer for the tree in the test, and it pins differences that are not about links.
  • Also run, as unchanged consumers: test/cli/format/conformance.test.ts, test/cli/lint/explain.test.ts, test/cli/lint/conformance.test.ts, test/internal/source-lints/, test/integration/bun-types/bun-types.test.ts.

Not in this change

  • The fenced code language: only html() resolves it. See markdown: report fenced code to a renderer with its info string, not an offset into the source #45508 above.
  • html() percent-encodes a destination. react(), render() and ansi() do not.
  • A character reference in an angle autolink: <https://example.com/?a=1&amp;b=2> keeps &amp; in html(), so it does here.
  • The wikilink target is the source text in html() and in react().
  • A literal NUL in a destination or a title is handed on as U+0000. &#0; gives U+FFFD.
  • A line ending in a code span is a space in html() and a line ending in react(), render() and ansi().

@robobun

robobun commented Oct 19, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 3:05 AM PT - Oct 21st, 2026

❌ @robobun, your commit d559e13 has 1 failures in Build #129860 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 46069

That installs a local version of the PR into your bun-46069 executable, so you can run:

bun-46069 --bun

@robobun

robobun commented Oct 19, 2026

Copy link
Copy Markdown
Collaborator Author

Status: the pull request is open. It waits for CI and for a maintainer review.

How I reproduced the problem on a release build of main (91e7e3b):

const text = String.raw`[wiki](<https://example.com/wiki/Foo_\(bar\)> "R&amp;D \"notes\""), ![logo](a\_b.png), <a@b.co>`;
const seen = [];
const take = (children, meta) => (seen.push(meta), children);
Bun.markdown.render(text, { link: take, image: take });
console.log(JSON.stringify(seen));
  • main: [{"href":"https://example.com/wiki/Foo_\\(bar\\)","title":"R&amp;D \\\"notes\\\""},{"src":"a\\_b.png"},{"href":"a@b.co"}]
  • this branch: [{"href":"https://example.com/wiki/Foo_(bar)","title":"R&D \"notes\""},{"src":"a_b.png"},{"href":"mailto:a@b.co"}]

Bun.markdown.react() gives the same values in its props. The four changed test files have 39 tests that fail on main and pass on this branch.

@coderabbitai

coderabbitai Bot commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Repository: oven-sh/bun/.coderabbit.yaml
  • Review profile: ASSERTIVE
  • Plan: Advanced
  • Run ID: 73e06ce4-b49f-435f-a80b-9fdf85220806
📥 Commits

Reviewing files that changed from the base of the PR and between 8997cb8 and d559e13.

📒 Files selected for processing: 1
  • src/format/markdown/parse.rs

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 1 remain after this review.

Walkthrough

Markdown link and image metadata now resolves backslash escapes and character references. Autolink metadata includes its scheme. ANSI image handling uses resolved and written paths and sanitizes terminal addresses. Tests and documentation cover the updated behavior.

Changes

Markdown metadata resolution

Layer / File(s) Summary
Track and resolve parsed link values
src/md/types.rs, src/md/helpers.rs, src/md/links.rs, src/md/ref_defs.rs, src/md/blocks.rs, src/md/inlines.rs, src/format/markdown/parse.rs
Span details now provide resolved and as-written values. Inline and reference parsing tracks escapes in destinations and titles.
Use resolved values in metadata APIs
src/runtime/api/MarkdownObject.rs, src/md/html_renderer.rs, test/js/bun/md/*, docs/runtime/markdown.mdx, packages/bun-types/bun.d.ts
Runtime metadata uses resolved destinations and titles. HTML rendering retains written inputs. Tests and documentation cover resolved values, autolink schemes, and HTML escaping.
Resolve image addresses for terminal output
src/md/ansi_renderer.rs, test/cli/run/markdown-entrypoint.test.ts, test/js/bun/md/md-edge-cases.test.ts
ANSI image handling tries resolved and written local paths. Terminal-sequence addresses are sanitized before emission. Tests cover local paths, remote queries, and control-byte handling.

Suggested reviewers: jarred-sumner

Priority: ➖ Normal

Severity of issue fixed: Medium

Merge Risk: ⚪ Minimal · up to d559e

No concrete merge-blocking risk is established by the reviewed changes.

Pre-merge checks: Passed 4
Check name Status Explanation
Title check Passed The title clearly identifies the main change: resolved link and title values are provided through SpanDetail accessors to markdown APIs.
Description check Passed The description is detailed and covers the problem, fix, scope, tradeoffs, design, tests, and verification. It does not use the template headings exactly, but it provides the required information.
Linked Issues check Passed Issue #31936 requires Bun.markdown.react() to include mailto: for email autolinks. SpanDetail::href() adds mailto: for email autolinks and http:// for www. autolinks. The React renderer us…
Out of Scope Changes check Passed The parser, SpanDetail accessors, render(), ansi(), image prefetch, documentation, and tests use one shared link-metadata contract. These changes support consistent resolved values and the linke…
Full details: Linked Issues check

Explanation

Issue #31936 requires Bun.markdown.react() to include mailto: for email autolinks. SpanDetail::href() adds mailto: for email autolinks and http:// for www. autolinks. The React renderer uses href(). md-react.test.ts verifies the email case and matching schemes. The implementation satisfies the linked coding requirement.

Full details: Out of Scope Changes check

Explanation

The parser, SpanDetail accessors, render(), ansi(), image prefetch, documentation, and tests use one shared link-metadata contract. These changes support consistent resolved values and the linked autolink behavior. No unrelated change is demonstrated.

  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

✨ Finishing Touches: 💡 1
🛠️ Fix failing CI checks: 💡
  • Commit to this branch
  • Create a new PR

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Additional findings (outside the current diff — GitHub can't attach inline comments there):

  • 🟡 src/md/ansi_renderer.rs — Terminal users clicking a link whose plain destination holds &#27;[ or &#27;] now get a truncated URL; on the base the same document gave the full URL. resolve_escapes turns &#27; into a raw ESC before strip_controls runs, and strip_controls at src/md/ansi_renderer.rs:2397-2423 then swallows the whole following CSI or OSC sequence, not just the ESC byte. The base only reached this for a raw ESC inside <...>; a plain destination rejected controls at src/md/links.rs:127, so &#27; was passed through as text. Fix: when sanitizing a URL, drop only the control byte itself and keep the bytes after it, so the OSC 8 target and the (url) fallback match what html() percent-encodes.

    Why this was flagged

    Input [a](https://example.com/x&#27;]y/long/path) rendered by bun file.md or Bun.markdown.ansi(). SpanDetail::href() at src/md/types.rs:286 now resolves &#27; to byte 0x1b (helpers::resolve_escapes, src/md/helpers.rs:444). resolve_href at src/md/ansi_renderer.rs:2481 passes that to sanitized_url -> strip_controls. At src/md/ansi_renderer.rs:2407-2419 the ] branch consumes every byte to the end of the slice looking for BEL or ST, so the OSC 8 target written at 582 and the (url) text at 651 become https://example.com/x. On the base, &#27; stayed as six literal characters because a plain destination cannot hold a raw control (src/md/links.rs:127 returns None), so the full URL was emitted. The pre_existing dismissal only holds for the <...> form with a raw ESC; the population is now every plain destination with a numeric character reference for a control. html() writes %1B]y/long/path and keeps the path. Remedy: in the URL variant of strip_controls, remove only disallowed bytes and never skip the characters following an ESC.

    Verification: scan_link_destination (src/md/links.rs:127) rejects raw controls in a plain destination but passes &#27; as text, so on the base the literal URL was emitted unchanged. After the change resolve_escapes maps 27 to 0x1b, and strip_controls (src/md/ansi_renderer.rs:2407-2419) drops the whole tail after ], so the OSC 8 target at line 582 and the (url) fallback at 651 become https://example.com/x.

Comment thread src/md/ansi_renderer.rs Resolved
Comment thread src/md/links.rs
let dest: Box<[u8]> = Box::from(&ref_def.dest[..]);
let title: Box<[u8]> = Box::from(&ref_def.title[..]);
let has_escape = ref_def.has_escape;
if !self.charge_ref_def_output(dest.len(), title.len()) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 (optional) Callers of react() and render() can get more reference-link output than the amplification budget allows; the base branch charged the exact bytes handed on. src/md/links.rs:513 charges dest.len() and title.len() of the raw RefDef, but MarkdownObject.rs:850 and :1364 now copy detail.href() and detail.title(), which resolve_escapes (helpers.rs:444) can make longer than the raw bytes, e.g. &nGt; (5 bytes) becomes 6 bytes of UTF-8. The guard at parser.rs:304 therefore under-counts by up to a fifth on every reference link that resolves. Fix: charge the resolved length (or the worst-case expansion) at both charge sites, links.rs:513 and :550, so the budget bounds what the renderer actually allocates.

Why this was flagged

Input: a document with one reference definition [r]: /&nGt;&nGt;&nGt;... whose destination is thousands of two-codepoint named entities, followed by as many [a][r] references as fit. Each reference reaches links.rs:510-513, where charge_ref_def_output (src/md/parser.rs:304-313) subtracts the raw dest length from a budget of 16 × input size capped at 1 MiB (parser.rs:360). ParseRenderer::enter_span at src/runtime/api/MarkdownObject.rs:850 then allocates Box::from(detail.href()), and resolve_escapes at src/md/helpers.rs:444-479 expands each &nGt; from 5 to 6 bytes, so the total bytes held by react() output exceed the budget by about 20%. On the base branch the Box copied detail.href as written, so the charge and the allocation matched exactly. The guard still exists but no longer bounds what it is meant to bound. Remedy: charge the resolved length, or scale the charge by the maximum entity expansion, at both reference-link sites.

Verification: nit. Trigger: a reference definition destination packed with entities such as &nGt; (5 raw bytes -> 6 UTF-8 bytes), referenced many times. src/md/links.rs:510-513 charges the raw RefDef bytes, while src/runtime/api/MarkdownObject.rs:850-851 now copies detail.href() / detail.title() after resolve_escapes. On the base branch those sites copied the raw bytes, so the charge was exact.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did not change the charge. The reasons:

  • The budget is the bound of md4c on the growth from reference links. It counts the bytes of the definition as written.
  • html() already writes more than the charged bytes. It percent-encodes a destination, so one byte can become three. It escapes a title, so " becomes &quot;.
  • A resolved value is at most 1.2 times as long as the written value. Of the 2,125 named character references, only &nGt; and &nLt; resolve to more bytes than they are written (5 to 6). A numeric reference never does. I checked each one against this branch.
  • So the output stays linear in the charged bytes.
  • A charge by the resolved length would move the point where html() stops resolving references. A resolved value is usually shorter than the written one, so html() would resolve more of them. This change does not touch the output of html().

@robobun

robobun commented Oct 19, 2026

Copy link
Copy Markdown
Collaborator Author

About the review finding on &#27; in a plain destination: fixed in 8997cb8.

  • From the address of a link or an image, ansi() now drops only the control bytes. What follows an ESC stays, because it is part of the address.
  • [a](https://example.com/x&#27;]y/long/path) gives a (https://example.com/x]y/long/path). Before this commit the address ended at x.
  • The test is in test/js/bun/md/md-edge-cases.test.ts: "a character reference cannot put a control byte into the output".

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review found no issues

No high-confidence issues detected in this change.

Still open from earlier reviews (1):

  • Unresolved: 1 minor or pre-existing.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bun.markdown.react() and Bun.markdown.html() do not produce the same results when using autolinks: true on email links

2 participants