Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
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: 14 additions & 12 deletions src/md/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,9 @@ pub enum ParserError {
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,
Comment thread
robobun marked this conversation as resolved.
}

bun_core::oom_from_alloc!(ParserError);
Expand Down Expand Up @@ -174,8 +177,13 @@ impl<'a> Parser<'a> {
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 @@ impl<'a> Parser<'a> {
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,15 +343,9 @@ pub fn render_to_html(

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.
match parser.process_doc() {
Ok(()) => {}
Err(ParserError::OutOfMemory) => return Err(ParserError::OutOfMemory),
Err(ParserError::JSError) | Err(ParserError::JSTerminated) => unreachable!(),
Err(ParserError::StackOverflow) => return Err(ParserError::StackOverflow),
}
parser.process_doc()?;
drop(parser);

Ok(html_renderer.to_owned_slice()?)
Expand All @@ -362,7 +364,7 @@ pub fn render_with_renderer<'a>(
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 @@ fn js_to_parser_err(e: bun_jsc::JsError) -> ParserError {
}
}

/// 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 @@ pub fn render_to_ansi(global_this: &JSGlobalObject, callframe: &CallFrame) -> Js
// 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())),
Comment thread
robobun marked this conversation as resolved.
};

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

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 @@ pub(crate) fn render(global_this: &JSGlobalObject, callframe: &CallFrame) -> JsR

// 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 @@ fn render_ast(
})?;

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