Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
4 changes: 4 additions & 0 deletions src/md/ansi_renderer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2721,6 +2721,10 @@ pub fn render_to_ansi<'a>(
theme: Theme<'a>,
) -> Result<Option<Box<[u8]>>, crate::parser::ParserError> {
use crate::parser::ParserError;
// `AnsiRenderer::init` reserves output space proportional to the input, so
// an input the parser cannot address has to be rejected before it is
// allocated for, not only when `Parser::init` sees it.
crate::parser::input_size(text)?;
let mut renderer = AnsiRenderer::init(text, theme);
match root::render_with_renderer(text, options, renderer.renderer()) {
Ok(()) => {}
Expand Down
33 changes: 19 additions & 14 deletions src/md/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -129,8 +129,8 @@ impl Default for BlockHeader {
}
}

/// `Parser`'s error type: the union
/// of `{ OutOfMemory, JSError, JSTerminated }` with `{ StackOverflow }`.
/// `Parser`'s error type: the union of `{ OutOfMemory, JSError, JSTerminated }`
/// with the parser-specific `{ StackOverflow, InputTooLarge }`.
// (`bun_jsc::JsError` covers the first three, but the md crate sits below
// `bun_jsc` in the layering, so the variants stay flat here.)
pub type Error = ParserError;
Expand All @@ -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 All @@ -151,6 +154,14 @@ impl From<ParserError> for bun_core::Error {
}
}

/// Every offset, mark and span boundary in the parser is an `OFF` (u32), so an
/// input of 2^32 bytes or more cannot be indexed. Callers that size anything
/// from the input length must reject it with this before allocating.
#[inline]
pub(crate) fn input_size(text: &[u8]) -> Result<OFF, ParserError> {
OFF::try_from(text.len()).map_err(|_| ParserError::InputTooLarge)
}

impl<'a> Parser<'a> {
pub fn get_block_header_at(&mut self, off: usize) -> &mut BlockHeader {
// SAFETY: `off` is produced by start_new_block / push_container_bytes which pad it
Expand All @@ -174,8 +185,8 @@ 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> {
let size = input_size(text)?;
let mut p = Parser {
text,
size,
Expand Down Expand Up @@ -218,7 +229,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 +346,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 +367,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