Skip to content

markdown: reject inputs of 2^32 bytes or more instead of panicking - #32752

Merged
Jarred-Sumner merged 5 commits into
mainfrom
farm/fbc702e4/md-input-too-large
Jun 26, 2026
Merged

markdown: reject inputs of 2^32 bytes or more instead of panicking#32752
Jarred-Sumner merged 5 commits into
mainfrom
farm/fbc702e4/md-input-too-large

Conversation

@robobun

@robobun robobun commented Jun 26, 2026

Copy link
Copy Markdown
Collaborator

What

Bun.markdown.html / .ansi / .render / .react crash the process on an input of 2^32 bytes or more:

Bun.markdown.html(new Uint8Array(2 ** 32));
panic: int cast: TryFromIntError(PosOverflow)

Why

Parser::init (src/md/parser.rs) converts the input length with OFF::try_from(text.len()).expect("int cast"). OFF is u32, and the largest allocatable ArrayBuffer is exactly 2^32 bytes, so the cast overflows and the process aborts. Every offset, mark and span boundary in the parser is an OFF, so such inputs cannot be represented at all; they should surface as a catchable error, not a panic on user input.

Fix

  • Add ParserError::InputTooLarge; Parser::init returns it instead of panicking (via a shared parser::input_size helper), and both public entry points propagate it, so every consumer of bun_md is covered.
  • md::render_to_ansi also checks the length before constructing the renderer: AnsiRenderer::init reserves output space proportional to the input, so nothing may be sized from an input the parser is going to reject.
  • The four Bun.markdown host functions report it as a RangeError with code ERR_OUT_OF_RANGE: The value of "input.byteLength" is out of range. It must be <= 4294967295. Received 4294967296. They now share one ParserError to JS exception mapping (parser_err_to_js); previously Bun.markdown.html collapsed every parser error (including StackOverflow) into an out-of-memory error.
  • The remaining bun_md consumers (the CLI markdown preview, the bundler loader) already route unrecognized parser errors to their generic error paths.

Test

test/js/bun/md/md-edge-cases.test.ts spawns one subprocess that calls all four entry points with a 2^32-byte Uint8Array (virtual only, never written, so the allocation is cheap) and asserts the exact RangeError from each. On the unfixed build the child dies with the panic above on the first call.

bun bd test test/js/bun/md/                                                      # 1066 pass
USE_SYSTEM_BUN=1 bun test test/js/bun/md/md-edge-cases.test.ts -t "rejects a 2"  # 4 fail (panic)

Parser::init stored the input length via OFF::try_from(text.len()).expect(),
so Bun.markdown.html/ansi/render/react with a 4 GiB typed array crashed with
'panic: int cast: TryFromIntError(PosOverflow)'. Every offset in the parser
is a u32, so such inputs cannot be represented at all.

Add ParserError::InputTooLarge, return it from Parser::init, and surface it
from the Bun.markdown host functions as a RangeError (ERR_OUT_OF_RANGE)
naming input.byteLength and the 4294967295-byte limit. The four host
functions now share one ParserError -> JS exception mapping, which also
stops StackOverflow from being reported as out-of-memory by
Bun.markdown.html.
@robobun

robobun commented Jun 26, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 7:51 AM PT - Jun 26th, 2026

@robobun, your commit dd9dae6 has some failures in Build #64986 (All Failures)


🧪   To try this PR locally:

bunx bun-pr 32752

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

bun-32752 --bun

@coderabbitai

coderabbitai Bot commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@robobun, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 8 minutes and 41 seconds. Learn how PR review limits work.

Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file).

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based credits.

🚦 How do rate limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: e72914db-3d55-4402-8cbd-3814580980e3

📥 Commits

Reviewing files that changed from the base of the PR and between 5a01093 and dd9dae6.

📒 Files selected for processing (3)
  • src/md/ansi_renderer.rs
  • src/md/parser.rs
  • test/js/bun/md/md-edge-cases.test.ts

Walkthrough

Markdown parsing now rejects inputs too large for the parser offset type, propagates that error through render entry points, maps it to JS range errors in the runtime API, and adds subprocess tests for 2^32-byte inputs.

Changes

Oversized markdown input handling

Layer / File(s) Summary
Parser limit and constructor
src/md/parser.rs
ParserError adds InputTooLarge, and Parser::init checks text.len() against OFF before returning Result<Parser, ParserError>.
Parser render entry points
src/md/parser.rs
render_to_html and render_with_renderer call the fallible initializer, and render_to_html no longer matches InputTooLarge during document processing.
Runtime error translation
src/runtime/api/MarkdownObject.rs
Markdown host functions route parser failures through parser_err_to_js, which maps InputTooLarge to a JS range error and preserves the other parser error variants.
Oversize-input tests
test/js/bun/md/md-edge-cases.test.ts
A subprocess test exercises Bun.markdown.html, ansi, render, and react with Uint8Array(2 ** 32) inputs and checks for SKIP or the expected range error output.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and accurately summarizes the main change: rejecting oversized markdown inputs instead of panicking.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description check ✅ Passed The PR description includes the change, rationale, fix, and test details, though it uses different headings than the template.

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

@coderabbitai coderabbitai 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.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/js/bun/md/md-edge-cases.test.ts`:
- Around line 1199-1231: The boundary test only covers the failing 2^32 case, so
it can miss an off-by-one regression where the max valid size is incorrectly
rejected. Extend the existing Bun.markdown.each coverage in
md-edge-cases.test.ts to add a sibling case for the same entry points (html,
ansi, render, react) that uses Uint8Array(2 ** 32 - 1) and verifies it succeeds,
while keeping the current 2^32 rejection check. Ensure the new assertion sits
alongside the existing expr-based test setup so the limit behavior is covered as
“at the limit succeeds, one past fails.”
- Around line 1220-1231: The subprocess test in the Bun.spawn call is inheriting
stderr, which lets native diagnostics escape without being asserted. Update the
md-edge-cases test to pipe stderr instead of inheriting it, using the existing
Bun.spawn/proc.stdout/proc.exited flow, and add an assertion that stderr remains
empty or is included in the same result check as stdout and exitCode. This keeps
the oversized input regression covered by a stronger invariant while preserving
the current stdout expectations.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: fee5378c-f38b-49fe-9223-a3fb77975ba4

📥 Commits

Reviewing files that changed from the base of the PR and between 0589548 and b066772.

📒 Files selected for processing (3)
  • src/md/parser.rs
  • src/runtime/api/MarkdownObject.rs
  • test/js/bun/md/md-edge-cases.test.ts

Comment thread test/js/bun/md/md-edge-cases.test.ts Outdated
Comment thread test/js/bun/md/md-edge-cases.test.ts
Comment thread src/md/parser.rs Outdated
Comment thread src/md/parser.rs Outdated
Comment thread src/runtime/api/MarkdownObject.rs
Comment thread src/md/parser.rs
…tput space

AnsiRenderer::init reserves 1.5x the input length for the output buffer, and
ran before Parser::init's length check, so Bun.markdown.ansi could still
abort inside Vec::reserve on a host that cannot reserve ~6 GiB. Hoist the
check into md::render_to_ansi via a shared parser::input_size helper so
nothing is sized from an input the parser is going to reject.

@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.

LGTM — both earlier findings (the ansi pre-reserve ordering and the stale doc comment) are addressed in 231ea41.

Extended reasoning...

Overview

This PR converts a process-aborting panic in Bun.markdown.{html,ansi,render,react} on inputs ≥ 2^32 bytes into a catchable RangeError. It touches four files: src/md/parser.rs (adds ParserError::InputTooLarge, makes Parser::init fallible, extracts input_size()), src/md/ansi_renderer.rs (hoists the length check before the output-buffer pre-reserve), src/runtime/api/MarkdownObject.rs (consolidates the four host functions' ParserError → JS-exception mapping into one parser_err_to_js helper), and a subprocess regression test in md-edge-cases.test.ts.

Security risks

None introduced. The change is strictly defensive: it replaces an unconditional process abort on user input with a catchable JS exception. No new input is accepted that was previously rejected, and the error path does not expose any data.

Level of scrutiny

Moderate. The markdown parser is user-facing runtime code, but the change is narrow error-path plumbing rather than parsing logic. The new enum variant is matched exhaustively everywhere it can surface (Rust enforces this), Parser::init's only two callers are updated to ?-propagate, and the consolidated parser_err_to_js preserves the existing per-variant behavior while fixing the prior over-collapse to OOM in html().

Other factors

A human reviewer (alii) already engaged on the parser.rs design and their concern (avoiding unreachable! arms) was addressed in 5a01093 by switching to process_doc()?. Both of my earlier findings on this PR — the .ansi() path doing a ~6 GiB Vec::reserve before the new guard, and the stale doc-comment enumeration — were fixed exactly as suggested in 231ea41 and the threads are resolved. CodeRabbit's two test-style suggestions were withdrawn after the author explained the repo conventions. The new test covers all four entry points with the exact error string and gracefully skips on hosts that cannot reserve 4 GiB. The musl build failures reported by CI were against the earlier commit 5a01093, not the current head.

robobun added 2 commits June 26, 2026 12:30
…cess

Four sequential children, each reserving 4 GiB under a debug ASAN build, can
push a loaded runner past the per-test budget. One child exercises html, ansi,
render and react and reports all four results; same coverage, one spawn.
…unners

The child reserves 4 GiB and runs under a debug+ASAN build: give the test the
same explicit timeout its perf-test neighbors in this file use, and let the
ASan allocator return null so an allocation failure inside the child surfaces
as a catchable error (the SKIP branch) instead of an abort it cannot catch.

@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.

Looks good — both issues from my earlier pass (the ansi-path reserve ordering and the stale ParserError doc comment) are fixed in 231ea41, and the consolidated test covers all four entry points.

Extended reasoning...

Overview

This PR replaces a process-aborting panic in Bun.markdown.{html,ansi,render,react} with a catchable RangeError when the input is ≥ 2^32 bytes. It touches src/md/parser.rs (makes Parser::init fallible via a new ParserError::InputTooLarge variant and an input_size helper), src/md/ansi_renderer.rs (hoists the size check before the output-buffer reserve), src/runtime/api/MarkdownObject.rs (consolidates the four host functions' error mapping into one parser_err_to_js), and adds a subprocess regression test in test/js/bun/md/md-edge-cases.test.ts.

Security risks

None. This is a defensive change that converts an abort-on-user-input into a proper JS exception. No new input parsing, no auth/crypto/permission surface. The only user-visible behavior change beyond the new error is that Bun.markdown.html now reports StackOverflow as a stack-overflow error instead of collapsing it into OOM — a strict improvement.

Level of scrutiny

Moderate. The Rust signature change to Parser::init ripples to both public entry points (render_to_html, render_with_renderer), and the new enum variant required auditing every match site — all of which now go through parser_err_to_js with no wildcard arms. I verified the diff against my earlier finding: md::render_to_ansi now calls parser::input_size(text)? before AnsiRenderer::init, so the ~6 GiB reserve no longer runs for inputs the parser will reject. The doc comment on pub type Error was also updated.

Other factors

All review threads (alii's two on parser.rs, CodeRabbit's two on the test, and my two from the previous run) are resolved with the author's responses and follow-up commits. The bug-hunting pass on the current revision found nothing. The robobun CI comment flags failures on 7970229, but the subsequent commit dd9dae6 ("make the oversized-input test robust on loaded debug+ASAN runners") directly targets that — the test now uses a 30s timeout, JSON-encodes results from one subprocess, appends allocator_may_return_null=1 to ASAN_OPTIONS, and tolerates a SKIP outcome on hosts that cannot reserve 4 GiB.

@robobun

robobun commented Jun 26, 2026

Copy link
Copy Markdown
Collaborator Author

CI status for build 64986 (latest commit): every lane is green except darwin 26 aarch64 - test-bun, which failed before running any test:

Error: buildkite-agent artifact download timed out after 120s for step 'darwin-aarch64-build-bun'.
Refusing to continue with a partial download (would silently fall back to the wrong binary).

The same agent failed identically on every build it picked up today across unrelated PRs, so it is an infrastructure problem, not a test result. The markdown test lanes (including linux ASAN) are green.

Jarred-Sumner pushed a commit that referenced this pull request Jun 26, 2026
…e instead of panicking (#32764)

### Repro

All four abort the process on the release binary with `panic: int cast:
TryFromIntError(PosOverflow)`:

```js
Bun.JSON5.parse(new Uint8Array(2 ** 31 + 2).fill(32));

const n = 2 ** 31 + 8, b = new Uint8Array(n).fill(10);
b.set([97, 58, 32, 49, 10], n - 5);
Bun.YAML.parse(b);

Bun.TOML.parse("#" + "\u00e9".repeat(2 ** 30 + 4) + "\na=1");
Bun.JSONC.parse("//" + "\u00e9".repeat(2 ** 30 + 4) + "\n1");
```

Same bug class as #32752 (`Bun.markdown`).

### Why

Every parser behind `Bun.{JSON5,JSONC,TOML,YAML}.parse` records source
positions as an `i32`:

- JSONC and TOML go through `ast::usize2loc`, `start:
i32::try_from(loc).expect("int cast")` (`src/ast/lib.rs`)
- JSON5 builds every token with `start:
i32::try_from(self.pos).expect("int cast")` (`src/parsers/json5.rs`)
- YAML's `Pos::loc()` does `i32::try_from(self.0).expect("int cast")`
(`src/parsers/yaml.rs`)

JSON5 and YAML accept a `Blob` / `Buffer` directly, so a 2^32-byte
`Uint8Array` gets there in one call. All four accept a string, and JSC
strings are Latin-1 or UTF-16 internally, so a string well under 2 GiB
can still produce more than 2^31 UTF-8 bytes (the `\u00e9` repros
above). No entry point checked the length.

### Fix

All four host fns already share one entry point,
`with_text_format_source` (`src/runtime/api.rs`), which resolves the
argument to the `&[u8]` the parser will see. It now rejects anything the
parsers' offsets cannot represent, before `Source::init_path_string`:

```
RangeError [ERR_OUT_OF_RANGE]: The value of "input.byteLength" is out of range. It must be <= 2147483647. Received 2147483650
```

Same code and wording as #32752.

Not covered here, same `i32`-offset constraint but different entry
points: `Bun.Transpiler.transformSync` and anything that loads a file
into the JS parser or bundler. Those go through the shared `ast::Source`
/ logger machinery used by the whole build pipeline, which is a much
larger change than the four `Bun.*` parse APIs.

### Test

One test each in `test/js/bun/json5/json5.test.ts` and
`test/js/bun/yaml/yaml.test.ts`, asserting the exact error class, code
and message. The check happens before any byte of the input is read, so
the 2^31+2-byte `Uint8Array` is virtual pages that are never
materialized; each test runs in ~6ms.

JSONC and TOML only accept a string (`accept_blob_or_buffer` is false
for them), and the cheapest string whose UTF-8 form exceeds 2^31 bytes
commits ~3 GB both before and after the fix, so they have no automated
test. They call the identical guard five lines away from the two that
do.

```
bun bd test test/js/bun/json5/json5.test.ts   # 321 pass
bun bd test test/js/bun/yaml/yaml.test.ts     # 609 pass
bun bd test test/js/bun/jsonc/                # 14 pass
bun bd test test/js/bun/resolve/toml/         # 17 pass

USE_SYSTEM_BUN=1 bun test <json5,yaml files> -t "2**31"   # 2 fail
# json5: gets SyntaxError instead of RangeError
# yaml:  returns null instead of throwing
```
@Jarred-Sumner
Jarred-Sumner merged commit b99024a into main Jun 26, 2026
78 of 79 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the farm/fbc702e4/md-input-too-large branch June 26, 2026 20:48
@alii

alii commented Jun 29, 2026

Copy link
Copy Markdown
Member

@robobun heads-up: this merged but the fix is incomplete — please open a follow-up PR. It only guards text.len(). Still panics in RELEASE on allocatable sub-2^32 input: (1) containers.rs:18 u32::try_from(block_bytes.len()).expect() — block_bytes is ~16x-amplified vs input, so ~300-512MB of nested >/# aborts there; route it through ParserError::InputTooLarge. (2) accepted-boundary overflow: input_size accepts len==u32::MAX, but line_analysis.rs:254 does off+9 <= size then slices (off+1)..(off+9)off+9 wraps → slice panic in release. Reject with margin: len < OFF::MAX - 9. (3) ansi reserve(len*1.5)/extend_from_slice abort uncatchably on OOM — use try_reserve. Add accepted-boundary + deep-nest tests.

@robobun

robobun commented Jun 29, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks for the catch, opening a follow-up now. Plan:

  • containers.rs / blocks.rs: cap block_bytes growth at the two sites that grow it (push_container_bytes, start_new_block) so every stored u32 block offset stays representable, surfacing through the same catchable channel. I am keeping it a separate ParserError variant from InputTooLarge so the JS error can name the limit that was hit instead of reporting a wrong input.byteLength bound for a 300 MB input.
  • input_size now leaves the parser's maximum fixed lookahead of headroom below OFF::MAX (the 9-byte <![CDATA[ probe at line_analysis.rs:254 is the largest constant in the crate), so off + k arithmetic cannot wrap; the maximum reported by the RangeError drops to 4294967286 accordingly.
  • AnsiRenderer's output buffer: try_reserve for the 1.5x pre-reserve and for the write paths, setting the existing oom flag that render_to_ansi already converts into an error; the html renderer's duplicate OutputBuffer gets the same treatment.

On tests: the accepted-boundary side is covered (a 2^32 - 1 byte input must now be rejected up front with the new bound; previously it was accepted and scanned). I do not see an economical regression test for the block_bytes overflow: the guard can only fire after the parser has materialized ~4 GiB of block headers (~270 million blocks from ~256 MB of nested >), which takes minutes under a debug ASAN build, so that path is by inspection, and the OOM path likewise cannot be triggered deterministically in CI. Will link the PR here shortly.

@robobun

robobun commented Jun 29, 2026

Copy link
Copy Markdown
Collaborator Author

Follow-up is up: #33078 covers all three (block-metadata cap via a new ParserError::TooManyBlocks, the 9-byte lookahead margin on input_size, and try_reserve in the ANSI output buffer).

alii pushed a commit that referenced this pull request Jun 30, 2026
Follow-ups to #32752, requested in review after it merged. The first fix only
guarded the input length read by Parser::init; three sibling limits in the
same path were still fatal in release builds:

- block_bytes growth: every block offset is a u32 (Container.block_byte_off,
  the casts in push_container and enter_child_containers), but nothing
  bounded the buffer, so a few hundred MB of nested blockquotes or headings
  panicked on 'int cast' in push_container. Reject the document at the two
  sites that grow the buffer (push_container_bytes, start_new_block) with a
  new ParserError::TooManyBlocks, reported to JS as a RangeError.
- lookahead margin: input_size accepted lengths up to u32::MAX, but the
  parser probes up to 9 bytes past an offset (the <![CDATA[ check), so
  off + 9 could wrap at the very end of a maximum-length input and panic on
  the slice. The accepted maximum is now u32::MAX - 9 and the RangeError
  reports it.
- the ANSI renderer's OutputBuffer aborted on OOM (plain reserve and
  extend_from_slice); it now uses try_reserve and its existing oom flag,
  exactly like the HTML renderer's OutputBuffer already did, so allocation
  failure surfaces as the catchable error render_to_ansi already maps.
Jarred-Sumner pushed a commit that referenced this pull request Jun 30, 2026
Follow-up to #32752, requested by @alii after it merged
(#32752 (comment) has
the plan). That fix only guarded the input length that `Parser::init`
reads; three sibling limits in the same path were still fatal in release
builds.

### What

1. **Block metadata overflow.** A block's offset into `block_bytes` is
stored as a `u32` (`Container.block_byte_off`, and the casts in
`push_container` / `enter_child_containers` that produce it), but
nothing bounded `block_bytes`, which grows by one 16-byte header per
block plus 12 bytes per accumulated line. A few hundred MB of nested `>`
or `#` (16x amplification) therefore still aborted in release:

   ```
panic: int cast: TryFromIntError(PosOverflow) # containers.rs,
push_container
   ```

`block_bytes` is now capped at `MAX_BLOCK_BYTES` through one shared
`parser::check_block_bytes_len`, called by the only two growers:
`Parser::append_block_header` (the align/check/grow/write sequence that
`start_new_block` and `push_container_bytes` used to duplicate) and
`end_current_block`'s line append. A `const _: () = assert!(..)` next to
the constant encodes the headroom proof, so enlarging `BlockHeader` is a
compile error instead of a silently shrunk margin, and the surviving
`u32` casts are annotated with the invariant that makes them safe. JS
sees a `RangeError` (`ERR_OUT_OF_RANGE`) saying the input requires more
block metadata than the parser can address, because an
`input.byteLength` bound would be wrong for a 300 MB input.

2. **Lookahead overflow at the accepted boundary.** `input_size`
accepted lengths up to `u32::MAX`, but `is_html_block_start_condition`
probes 9 bytes past an in-bounds offset in `OFF` (u32) arithmetic, so
`off + 9` could wrap at the very end of a maximum-length input, pass the
`<= size` test, and index out of bounds. The accepted maximum is now
`MAX_INPUT_LEN = u32::MAX - MAX_LOOKAHEAD = 4294967286`, where
`MAX_LOOKAHEAD` is derived from the probe's own constant
(`line_analysis::CDATA_OPEN`) rather than restated as a literal, so the
two cannot drift. Its doc states the real invariant: the longest
`OFF`-typed fixed addition (longer probes such as `match_html_tag`'s add
in `usize` and cannot wrap).

3. **Uncatchable OOM in the ANSI renderer.** `AnsiRenderer::init`
reserved 1.5x the input with the aborting `Vec::reserve`, and its output
path used plain `extend_from_slice`/`push`, so a failed allocation
aborted the process even though the renderer already carries an `oom`
flag that `render_to_ansi` converts into an error. The two renderers'
byte-identical `OutputBuffer` structs are now one shared type in
`src/md/output.rs` using `try_reserve`, and the ANSI staging buffers
that hold output-scale data before it reaches the sink (`code_buf`,
`heading_buf`, `image_alt`, `table_cell_buf`, plus the
`table_rows`/`table_cells` vectors) record their failure on the same
flag, matching what the HTML renderer already did for its heading
buffer. Scope, to be explicit: that flag covers the output sink and
those staging buffers; the parser proper and the renderer's remaining
per-node scratch keep the runtime's global abort-on-OOM policy, as
before this PR.

### Tests

`test/js/bun/md/md-edge-cases.test.ts`, in subprocesses so the
`bun:internal-for-testing` knob and the huge virtual buffers cannot leak
into other tests:

- The oversized-input test also covers the largest allocatable length
that must still be rejected (`2^32 - 1`, the accepted-boundary side) and
asserts the new maximum in the message. On the previous code that length
is accepted and the child grinds through a 4 GiB scan until the test
times out.
- New `setMaxMarkdownBlockBytesForTesting(n)` in
`bun:internal-for-testing` (modeled on
`setSyntheticAllocationLimitForTesting`): lowers the block-metadata cap,
never raises it past the real one, returns the previous value. The new
test shrinks it to exactly 40 single-line paragraphs of metadata (28
bytes each) and proves the boundary: 40 paragraphs render, 41 throw, a
nested blockquote throws through the container-header path, and both
render again once the cap is restored. The `ERR_OUT_OF_RANGE` code and
message are asserted verbatim, so the guard, the error mapping, and the
`?` propagation through the growers are all load-bearing.
- Also raises the child budget of the neighboring reference-definition
perf test from 30s to 75s: its 220k-line debug+ASAN child needs 24-45s
on a loaded runner (observed SIGKILLed at 30.1s), which made it the
flakiest test in this file; workload and assertions unchanged. The
oversized-input test's redundant inner 20s child timeout is gone for the
same reason: the outer test timeout already bounds and reaps a hung
child.

Not covered by a test: the `try_reserve` conversion itself, since an
allocation failure cannot be triggered deterministically in CI.

```
bun bd test test/js/bun/md/   # 1064 pass
cargo test -p bun_md          # 14 pass
```
liooil pushed a commit to liooil/poly that referenced this pull request Aug 7, 2026
Follow-up to #32752, requested by @alii after it merged
(oven-sh/bun#32752 (comment) has
the plan). That fix only guarded the input length that `Parser::init`
reads; three sibling limits in the same path were still fatal in release
builds.

### What

1. **Block metadata overflow.** A block's offset into `block_bytes` is
stored as a `u32` (`Container.block_byte_off`, and the casts in
`push_container` / `enter_child_containers` that produce it), but
nothing bounded `block_bytes`, which grows by one 16-byte header per
block plus 12 bytes per accumulated line. A few hundred MB of nested `>`
or `#` (16x amplification) therefore still aborted in release:

   ```
panic: int cast: TryFromIntError(PosOverflow) # containers.rs,
push_container
   ```

`block_bytes` is now capped at `MAX_BLOCK_BYTES` through one shared
`parser::check_block_bytes_len`, called by the only two growers:
`Parser::append_block_header` (the align/check/grow/write sequence that
`start_new_block` and `push_container_bytes` used to duplicate) and
`end_current_block`'s line append. A `const _: () = assert!(..)` next to
the constant encodes the headroom proof, so enlarging `BlockHeader` is a
compile error instead of a silently shrunk margin, and the surviving
`u32` casts are annotated with the invariant that makes them safe. JS
sees a `RangeError` (`ERR_OUT_OF_RANGE`) saying the input requires more
block metadata than the parser can address, because an
`input.byteLength` bound would be wrong for a 300 MB input.

2. **Lookahead overflow at the accepted boundary.** `input_size`
accepted lengths up to `u32::MAX`, but `is_html_block_start_condition`
probes 9 bytes past an in-bounds offset in `OFF` (u32) arithmetic, so
`off + 9` could wrap at the very end of a maximum-length input, pass the
`<= size` test, and index out of bounds. The accepted maximum is now
`MAX_INPUT_LEN = u32::MAX - MAX_LOOKAHEAD = 4294967286`, where
`MAX_LOOKAHEAD` is derived from the probe's own constant
(`line_analysis::CDATA_OPEN`) rather than restated as a literal, so the
two cannot drift. Its doc states the real invariant: the longest
`OFF`-typed fixed addition (longer probes such as `match_html_tag`'s add
in `usize` and cannot wrap).

3. **Uncatchable OOM in the ANSI renderer.** `AnsiRenderer::init`
reserved 1.5x the input with the aborting `Vec::reserve`, and its output
path used plain `extend_from_slice`/`push`, so a failed allocation
aborted the process even though the renderer already carries an `oom`
flag that `render_to_ansi` converts into an error. The two renderers'
byte-identical `OutputBuffer` structs are now one shared type in
`src/md/output.rs` using `try_reserve`, and the ANSI staging buffers
that hold output-scale data before it reaches the sink (`code_buf`,
`heading_buf`, `image_alt`, `table_cell_buf`, plus the
`table_rows`/`table_cells` vectors) record their failure on the same
flag, matching what the HTML renderer already did for its heading
buffer. Scope, to be explicit: that flag covers the output sink and
those staging buffers; the parser proper and the renderer's remaining
per-node scratch keep the runtime's global abort-on-OOM policy, as
before this PR.

### Tests

`test/js/bun/md/md-edge-cases.test.ts`, in subprocesses so the
`bun:internal-for-testing` knob and the huge virtual buffers cannot leak
into other tests:

- The oversized-input test also covers the largest allocatable length
that must still be rejected (`2^32 - 1`, the accepted-boundary side) and
asserts the new maximum in the message. On the previous code that length
is accepted and the child grinds through a 4 GiB scan until the test
times out.
- New `setMaxMarkdownBlockBytesForTesting(n)` in
`bun:internal-for-testing` (modeled on
`setSyntheticAllocationLimitForTesting`): lowers the block-metadata cap,
never raises it past the real one, returns the previous value. The new
test shrinks it to exactly 40 single-line paragraphs of metadata (28
bytes each) and proves the boundary: 40 paragraphs render, 41 throw, a
nested blockquote throws through the container-header path, and both
render again once the cap is restored. The `ERR_OUT_OF_RANGE` code and
message are asserted verbatim, so the guard, the error mapping, and the
`?` propagation through the growers are all load-bearing.
- Also raises the child budget of the neighboring reference-definition
perf test from 30s to 75s: its 220k-line debug+ASAN child needs 24-45s
on a loaded runner (observed SIGKILLed at 30.1s), which made it the
flakiest test in this file; workload and assertions unchanged. The
oversized-input test's redundant inner 20s child timeout is gone for the
same reason: the outer test timeout already bounds and reaps a hung
child.

Not covered by a test: the `try_reserve` conversion itself, since an
allocation failure cannot be triggered deterministically in CI.

```
bun bd test test/js/bun/md/   # 1064 pass
cargo test -p bun_md          # 14 pass
```
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.

3 participants