Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
55 changes: 55 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,58 @@ 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 by
// every entry point. One subprocess covers all four: a crash must not take
// down the test runner, and one 4 GiB reservation (virtual only, never
// written) keeps this cheap. The SKIP branch covers runners that cannot
// reserve 4 GiB; under ASAN the allocator must be allowed to return null
// for that to surface as a catchable error rather than an abort. The
// explicit timeout is for the debug+ASAN lanes, where a spawned child is
// slow under load (same as the linear-time test above).
test("html, ansi, render and react reject a 2^32-byte input", async () => {
const script = `
let big;
try {
big = new Uint8Array(2 ** 32);
} catch {
console.log(JSON.stringify("SKIP"));
process.exit(0);
}
const runs = [
() => Bun.markdown.html(big),
() => Bun.markdown.ansi(big),
() => Bun.markdown.render(big, {}),
() => Bun.markdown.react(big, undefined, { reactVersion: 18 }),
];
const results = [];
for (const run of runs) {
try {
run();
results.push("UNEXPECTED_SUCCESS");
} catch (e) {
results.push([e.constructor.name, e.code, e.message].join(" | "));
}
}
console.log(JSON.stringify(results));
`;
await using proc = Bun.spawn({
cmd: [bunExe(), "-e", script],
env: {
...bunEnv,
ASAN_OPTIONS: [bunEnv.ASAN_OPTIONS, "allocator_may_return_null=1"].filter(Boolean).join(":"),
},
stdout: "pipe",
stderr: "inherit",
});
const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]);
const rangeError =
'RangeError | ERR_OUT_OF_RANGE | The value of "input.byteLength" is out of range. It must be <= 4294967295. Received 4294967296';
expect(["SKIP", [rangeError, rangeError, rangeError, rangeError]]).toContainEqual(
JSON.parse(stdout.trim() || '"NO_OUTPUT"'),
);
expect(exitCode).toBe(0);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}, 30_000);
});
Loading