Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 19 additions & 7 deletions src/md/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,9 @@
JSError,
JSTerminated,
StackOverflow,
/// The input is longer than `OFF::MAX` bytes, so its offsets cannot be
/// represented by the parser's `u32` offset type.
InputTooLarge,

Check warning on line 146 in src/md/parser.rs

View check run for this annotation

Claude / Claude Code Review

Stale doc comment on Error type alias omits new InputTooLarge variant

Nit: the doc comment on `pub type Error = ParserError;` a few lines up still describes the type as "the union of `{ OutOfMemory, JSError, JSTerminated }` with `{ StackOverflow }`", which is now incomplete with `InputTooLarge` added. Either add the new variant to the enumeration or drop the explicit list — the enum definition is right below it anyway.
Comment thread
robobun marked this conversation as resolved.
}

bun_core::oom_from_alloc!(ParserError);
Expand Down Expand Up @@ -174,8 +177,13 @@
self.get_block_header_at(off)
}

fn init(text: &'a [u8], flags: Flags, rend: Renderer<'a>) -> Parser<'a> {
let size: OFF = OFF::try_from(text.len()).expect("int cast");
fn init(text: &'a [u8], flags: Flags, rend: Renderer<'a>) -> Result<Parser<'a>, ParserError> {
// Every offset, mark and span boundary in the parser is an `OFF`
// (u32). An input of 2^32 bytes or more cannot be indexed, so refuse
// it up front instead of panicking on the cast.
let Ok(size) = OFF::try_from(text.len()) else {
return Err(ParserError::InputTooLarge);
};
let mut p = Parser {
text,
size,
Expand Down Expand Up @@ -218,7 +226,7 @@
stack_check: StackCheck::init(),
};
p.build_mark_char_map();
p
Ok(p)
}

// All owned buffers are `Vec<_>`, so `Drop` is automatic — no explicit impl.
Expand Down Expand Up @@ -335,13 +343,17 @@

let mut html_renderer = HtmlRenderer::init(input, render_opts);

let mut parser = Parser::init(input, flags, html_renderer.renderer());
let mut parser = Parser::init(input, flags, html_renderer.renderer())?;

// HtmlRenderer never returns JSError/JSTerminated, so OutOfMemory is the only possible error.
// HtmlRenderer never returns JSError/JSTerminated, and InputTooLarge is
// only produced by `Parser::init` (already propagated above), so
// OutOfMemory and StackOverflow are the only possible errors.
match parser.process_doc() {
Ok(()) => {}
Err(ParserError::OutOfMemory) => return Err(ParserError::OutOfMemory),
Err(ParserError::JSError) | Err(ParserError::JSTerminated) => unreachable!(),
Err(ParserError::JSError)
| Err(ParserError::JSTerminated)
| Err(ParserError::InputTooLarge) => unreachable!(),
Comment thread
robobun marked this conversation as resolved.
Outdated
Comment thread
robobun marked this conversation as resolved.
Outdated
Err(ParserError::StackOverflow) => return Err(ParserError::StackOverflow),
}
drop(parser);
Expand All @@ -362,7 +374,7 @@
let _ = render_options; // Available for renderer implementations; parse layer does not use these.
let input = helpers::skip_utf8_bom(text);

let mut p = Parser::init(input, flags, rend);
let mut p = Parser::init(input, flags, rend)?;

p.process_doc()
}
52 changes: 35 additions & 17 deletions src/runtime/api/MarkdownObject.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
//! `Bun.markdown` — html/ansi/react/render host fns over `bun_md`.

use bun_core::StackCheck;
use bun_jsc::{ArrayBuffer, CallFrame, JSGlobalObject, JSValue, JsResult, MarkedArgumentBuffer};
use bun_jsc::{
ArrayBuffer, CallFrame, JSGlobalObject, JSValue, JsResult, MarkedArgumentBuffer,
RangeErrorOptions,
};
// Note: the `bun_md` crate's lib.rs is a
// thin mod-decl shim, so alias the `root` module (which re-exports BlockType,
// SpanType, TextType, SpanDetail, Renderer, helpers, types, ansi, …) as `md`.
Expand Down Expand Up @@ -32,6 +35,33 @@
}
}

/// Throw the JS exception for a `ParserError` returned by `bun_md`.
/// `input_len` is the byte length of the rendered input, reported back by the
/// `InputTooLarge` range error.
#[cold]
fn parser_err_to_js(
global_this: &JSGlobalObject,
err: ParserError,
input_len: usize,
) -> bun_jsc::JsError {
match err {
// A renderer callback threw (or the VM is terminating); the exception
// is already pending on the VM.
ParserError::JSError => bun_jsc::JsError::Thrown,
ParserError::JSTerminated => bun_jsc::JsError::Terminated,
ParserError::OutOfMemory => global_this.throw_out_of_memory(),
ParserError::StackOverflow => global_this.throw_stack_overflow(),
ParserError::InputTooLarge => global_this.throw_range_error(
input_len as i64,
RangeErrorOptions {
max: md::types::OFF::MAX as i64,
field_name: b"input.byteLength",
..Default::default()
},
),
}
}

struct PinnedView(ArrayBuffer);

impl PinnedView {
Expand Down Expand Up @@ -135,9 +165,7 @@
// path is unreachable but handle it safely.
return Err(global_this.throw_out_of_memory());
}
Err(ParserError::OutOfMemory) => return Err(global_this.throw_out_of_memory()),
Err(ParserError::StackOverflow) => return Err(global_this.throw_stack_overflow()),
Err(_) => return Err(global_this.throw_out_of_memory()),
Err(err) => return Err(parser_err_to_js(global_this, err, input.len())),

Check failure on line 168 in src/runtime/api/MarkdownObject.rs

View check run for this annotation

Claude / Claude Code Review

Bun.markdown.ansi still does a 6 GiB Vec::reserve before the new InputTooLarge check

The `.ansi()` path still does a ~6 GiB `Vec::reserve` *before* reaching the new `InputTooLarge` guard: `md::render_to_ansi` calls `AnsiRenderer::init` (which does `r.out.list.reserve(src_text.len() + src_text.len() / 2)`) and only then calls `render_with_renderer` → `Parser::init`. `Vec::reserve` aborts via `handle_alloc_error` on failure, so on hosts where the 4 GiB `Uint8Array` can be created but a 6 GiB malloc cannot (overcommit off, `RLIMIT_AS`, low-VA containers), `Bun.markdown.ansi(new Uin
Comment thread
robobun marked this conversation as resolved.
};

create_utf8_for_js(global_this, &result)
Expand Down Expand Up @@ -170,7 +198,7 @@

let result = match md::render_to_html_with_options(input, options) {
Ok(r) => r,
Err(_) => return Err(global_this.throw_out_of_memory()),
Err(err) => return Err(parser_err_to_js(global_this, err, input.len())),
};

create_utf8_for_js(global_this, &result)
Expand Down Expand Up @@ -287,12 +315,7 @@

// Run parser with the JS callback renderer
if let Err(err) = md::render_with_renderer(input, options, js_renderer.renderer()) {
return match err {
ParserError::JSError => Err(bun_jsc::JsError::Thrown),
ParserError::JSTerminated => Err(bun_jsc::JsError::Terminated),
ParserError::OutOfMemory => Err(global_this.throw_out_of_memory()),
ParserError::StackOverflow => Err(global_this.throw_stack_overflow()),
};
return Err(parser_err_to_js(global_this, err, input.len()));
}

// Return accumulated result
Expand Down Expand Up @@ -392,12 +415,7 @@
})?;

if let Err(err) = md::render_with_renderer(input, options, renderer.renderer()) {
return match err {
ParserError::JSError => Err(bun_jsc::JsError::Thrown),
ParserError::JSTerminated => Err(bun_jsc::JsError::Terminated),
ParserError::OutOfMemory => Err(global_this.throw_out_of_memory()),
ParserError::StackOverflow => Err(global_this.throw_stack_overflow()),
};
return Err(parser_err_to_js(global_this, err, input.len()));
}

Ok(renderer.get_result())
Expand Down
42 changes: 42 additions & 0 deletions test/js/bun/md/md-edge-cases.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1189,3 +1189,45 @@ describe("pathological autolink opener inputs", () => {
);
}, 90_000);
});

describe("inputs of 2^32 bytes or more", () => {
// The parser addresses its input with u32 offsets, so a 2^32-byte input
// cannot be represented and must be rejected with a catchable RangeError.
// Run in a subprocess so a crash cannot take down the test runner; the
// buffer is virtual-only (never written), so RSS stays small. The SKIP
// branch covers runners that cannot reserve 4 GiB at all.
test.each([
["html", "Bun.markdown.html(big)"],
["ansi", "Bun.markdown.ansi(big)"],
["render", "Bun.markdown.render(big, {})"],
["react", "Bun.markdown.react(big, undefined, { reactVersion: 18 })"],
])("%s rejects a 2^32-byte input", async (_name, expr) => {
const script = `
let big;
try {
big = new Uint8Array(2 ** 32);
} catch {
console.log("SKIP");
process.exit(0);
}
try {
${expr};
console.log("UNEXPECTED_SUCCESS");
} catch (e) {
console.log([e.constructor.name, e.code, e.message].join(" | "));
}
`;
await using proc = Bun.spawn({
cmd: [bunExe(), "-e", script],
env: bunEnv,
stdout: "pipe",
stderr: "inherit",
});
const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]);
expect([
'RangeError | ERR_OUT_OF_RANGE | The value of "input.byteLength" is out of range. It must be <= 4294967295. Received 4294967296',
"SKIP",
]).toContain(stdout.trim());
expect(exitCode).toBe(0);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});
});
Loading