diff --git a/src/codegen/generate-js2native.ts b/src/codegen/generate-js2native.ts index e32f5c4e0c8e..e7662b3d474c 100644 --- a/src/codegen/generate-js2native.ts +++ b/src/codegen/generate-js2native.ts @@ -48,6 +48,7 @@ const rustIdentifierPaths: Record = { "Counters.rs": "jsc/Counters.rs", "FrameworkRouter.rs": "runtime/bake/FrameworkRouter.rs", "Listener.rs": "runtime/socket/Listener.rs", + "MarkdownObject.rs": "runtime/api/MarkdownObject.rs", "SecureContext.rs": "runtime/api/bun/SecureContext.rs", "Stat.rs": "runtime/node/Stat.rs", "bindgen_test.rs": "jsc/bindgen_test.rs", diff --git a/src/js/internal-for-testing.ts b/src/js/internal-for-testing.ts index d04d7b9a7325..e0d3d9252a85 100644 --- a/src/js/internal-for-testing.ts +++ b/src/js/internal-for-testing.ts @@ -144,6 +144,16 @@ export const setSyntheticAllocationLimitForTesting: (limit: number) => number = 1, ); +// Shrink the markdown parser's block-metadata cap (in bytes) so its +// `TooManyBlocks` error is reachable without 4 GiB of input. The cap can only +// be lowered, never raised past the real limit. Returns the previous value so +// a test can restore it. +export const setMaxMarkdownBlockBytesForTesting: (limit: number) => number = $newRustFunction( + "MarkdownObject.rs", + "setMaxMarkdownBlockBytesForTesting", + 1, +); + export const npm_manifest_test_helpers = $rust("npm.rs", "PackageManifest.bindings.generate") as { /** * Returns the parsed manifest file. Currently only returns an array of available versions. diff --git a/src/md/ansi_renderer.rs b/src/md/ansi_renderer.rs index 9fa54c252235..5181c2b6e313 100644 --- a/src/md/ansi_renderer.rs +++ b/src/md/ansi_renderer.rs @@ -10,6 +10,7 @@ use bun_core::output::ansi_b; use bun_core::strings; use crate::helpers; +use crate::output::{OutputBuffer, try_extend, try_push}; use crate::root; use crate::types::{ self, Align, BlockType, JsResult, Renderer, RendererImpl, SpanDetail, SpanType, TextType, @@ -259,28 +260,6 @@ impl InlineStyle { } } -pub struct OutputBuffer { - pub list: Vec, - pub oom: bool, -} - -impl OutputBuffer { - fn write(&mut self, data: &[u8]) { - if self.oom { - return; - } - // Vec::extend aborts on OOM under the global mimalloc allocator. - self.list.extend_from_slice(data); - } - - fn write_byte(&mut self, b: u8) { - if self.oom { - return; - } - self.list.push(b); - } -} - impl<'a> AnsiRenderer<'a> { pub fn init(src_text: &'a [u8], theme: Theme<'a>) -> AnsiRenderer<'a> { let mut r = AnsiRenderer { @@ -316,7 +295,10 @@ impl<'a> AnsiRenderer<'a> { last_was_newline: true, blank_emitted: false, }; - r.out.list.reserve(src_text.len() + src_text.len() / 2); + // The output is usually ~1.5x the input; this is only a throughput + // hint, so on failure fall back to the incremental `try_reserve`s in + // `write` instead of aborting. + let _ = r.out.list.try_reserve(src_text.len() + src_text.len() / 2); r } @@ -547,19 +529,27 @@ impl<'a> AnsiRenderer<'a> { // normalized once the table finishes. let cells: Box<[TableCell]> = core::mem::take(&mut self.table_cells).into_boxed_slice(); - self.table_rows.push(TableRow { - cells, - is_header: self.in_thead, - }); + try_push( + &mut self.out.oom, + &mut self.table_rows, + TableRow { + cells, + is_header: self.in_thead, + }, + ); self.table_cells.clear(); } BlockType::Th | BlockType::Td => { self.in_cell = false; let owned = Box::<[u8]>::from(self.table_cell_buf.as_slice()); - self.table_cells.push(TableCell { - content: owned, - alignment: self.cell_align, - }); + try_push( + &mut self.out.oom, + &mut self.table_cells, + TableCell { + content: owned, + alignment: self.cell_align, + }, + ); } } } @@ -583,10 +573,8 @@ impl<'a> AnsiRenderer<'a> { SpanType::A => { self.link_depth += 1; if self.link_depth == 1 { - // Resolve final href (prefixes for autolinks). On OOM - // we leave link_href null so leaveSpan doesn't try to - // free a literal. - self.link_href = resolve_href(&detail).ok(); + // Resolve final href (prefixes for autolinks). + self.link_href = Some(resolve_href(&detail)); if self.theme.colors && self.theme.hyperlinks { if let Some(href) = &self.link_href { // OSC 8 hyperlink start @@ -748,19 +736,19 @@ impl<'a> AnsiRenderer<'a> { /// heading buffer, table cell, image alt, or directly to output). fn write_content(&mut self, data: &[u8]) { if self.image_depth > 0 { - self.image_alt.extend_from_slice(data); + try_extend(&mut self.out.oom, &mut self.image_alt, data); return; } if self.in_code_block { - self.code_buf.extend_from_slice(data); + try_extend(&mut self.out.oom, &mut self.code_buf, data); return; } if self.heading_level > 0 { - self.heading_buf.extend_from_slice(data); + try_extend(&mut self.out.oom, &mut self.heading_buf, data); return; } if self.in_cell { - self.table_cell_buf.extend_from_slice(data); + try_extend(&mut self.out.oom, &mut self.table_cell_buf, data); return; } // Normal paragraph flow: respect wrapping + indent. @@ -942,16 +930,16 @@ impl<'a> AnsiRenderer<'a> { while i < bytes.len() && bytes[i] != 0x1b { i += 1; } - self.image_alt.extend_from_slice(&bytes[start..i]); + try_extend(&mut self.out.oom, &mut self.image_alt, &bytes[start..i]); } return; } if self.in_cell { - self.table_cell_buf.extend_from_slice(bytes); + try_extend(&mut self.out.oom, &mut self.table_cell_buf, bytes); return; } if self.heading_level > 0 { - self.heading_buf.extend_from_slice(bytes); + try_extend(&mut self.out.oom, &mut self.heading_buf, bytes); return; } self.out.write(bytes); @@ -1116,11 +1104,11 @@ impl<'a> AnsiRenderer<'a> { return; } if self.in_cell { - self.table_cell_buf.extend_from_slice(data); + try_extend(&mut self.out.oom, &mut self.table_cell_buf, data); return; } if self.heading_level > 0 { - self.heading_buf.extend_from_slice(data); + try_extend(&mut self.out.oom, &mut self.heading_buf, data); return; } self.out.write(data); @@ -2438,7 +2426,7 @@ fn extract_language(src_text: &[u8], info_beg: u32) -> &[u8] { /// Build the final href string with autolink prefixes (mailto:, http://). /// Caller owns the returned memory. -fn resolve_href(detail: &SpanDetail) -> Result, bun_alloc::AllocError> { +fn resolve_href(detail: &SpanDetail) -> Box<[u8]> { let mut buf: Vec = Vec::new(); if detail.autolink_email { buf.extend_from_slice(b"mailto:"); @@ -2448,7 +2436,7 @@ fn resolve_href(detail: &SpanDetail) -> Result, bun_alloc::AllocError> } let mut scratch: Vec = Vec::new(); buf.extend_from_slice(sanitize_source_text(detail.href, &mut scratch)); - Ok(buf.into_boxed_slice()) + buf.into_boxed_slice() } // ======================================== diff --git a/src/md/blocks.rs b/src/md/blocks.rs index 7f9dcccd1fe4..9fa9f50279a0 100644 --- a/src/md/blocks.rs +++ b/src/md/blocks.rs @@ -3,7 +3,6 @@ use crate::helpers; use crate::parser::{self, Parser}; use crate::types::{self, BlockType, Container, Line, OFF, VerbatimLine}; -use bun_collections::VecExt as _; use core::mem::{align_of, size_of}; type BlockHeader = parser::BlockHeader; @@ -48,7 +47,7 @@ impl Parser<'_> { p_end: &mut OFF, pivot_line: &Line, line: &mut Line, - ) -> Result<(), bun_alloc::AllocError> { + ) -> Result<(), parser::Error> { let mut off = off_start; let mut total_indent: u32 = 0; let mut n_parents: u32 = 0; @@ -692,7 +691,7 @@ impl Parser<'_> { cur_line_idx: usize, line_buf: &mut [Line; 2], line_idx: &mut usize, - ) -> Result<(), bun_alloc::AllocError> { + ) -> Result<(), parser::Error> { // Index into line_buf via cur_line_idx instead of taking a `&mut Line` // parameter, which would alias line_buf. let line = &mut line_buf[cur_line_idx]; @@ -832,7 +831,7 @@ impl Parser<'_> { Ok(()) } - pub fn start_new_block(&mut self, line: &Line) -> Result<(), bun_alloc::AllocError> { + pub fn start_new_block(&mut self, line: &Line) -> Result<(), parser::Error> { let block_type: BlockType = match line.r#type { LineType::Hr => BlockType::Hr, LineType::Atxheader => BlockType::H, @@ -842,24 +841,13 @@ impl Parser<'_> { _ => BlockType::P, }; - // Align block_bytes for Block alignment - let align_mask: usize = align_of::() - 1; - let cur_len = self.block_bytes.len(); - let aligned = (cur_len + align_mask) & !align_mask; - let needed = aligned + size_of::(); - self.block_bytes.ensure_total_capacity(needed); - // Zero-fill to `needed`; bytes in [aligned, needed) are immediately - // overwritten by the BlockHeader write below. - self.block_bytes.resize(needed, 0); - - let hdr = self.get_block_header_at(aligned); - *hdr = BlockHeader { + let aligned = self.append_block_header(BlockHeader { block_type, _pad: [0; 3], flags: 0, data: line.data, n_lines: 0, - }; + })?; self.current_block = Some(aligned); self.current_block_lines.clear(); @@ -879,7 +867,7 @@ impl Parser<'_> { Ok(()) } - pub fn end_current_block(&mut self) -> Result<(), bun_alloc::AllocError> { + pub fn end_current_block(&mut self) -> Result<(), parser::Error> { if let Some(cb_off) = self.current_block { // Capture the header fields, drop the &mut borrow, then access // other &self fields. @@ -926,6 +914,9 @@ impl Parser<'_> { self.current_block_lines.len() * size_of::(), ) }; + // The block's lines land in `block_bytes` too (12 bytes per + // line), not just its header, so this growth needs the same cap. + parser::check_block_bytes_len(self.block_bytes.len() + line_bytes.len())?; self.block_bytes.extend_from_slice(line_bytes); self.current_block = None; } diff --git a/src/md/containers.rs b/src/md/containers.rs index c38ec8e2b43f..527a30d0cbcc 100644 --- a/src/md/containers.rs +++ b/src/md/containers.rs @@ -14,7 +14,8 @@ impl Parser<'_> { self.containers[self.n_containers as usize] = *c; } - // Record block_byte offset in the container + // Record block_byte offset in the container. + // In range: every `block_bytes` grower enforces `check_block_bytes_len`. let block_off: u32 = u32::try_from(self.block_bytes.len()).expect("int cast"); self.containers[self.n_containers as usize].block_byte_off = block_off; @@ -27,29 +28,18 @@ impl Parser<'_> { block_type: BlockType, data: u32, flags: u32, - ) -> Result<(), AllocError> { - let align_mask: usize = align_of::() - 1; - let cur_len = self.block_bytes.len(); - let aligned = (cur_len + align_mask) & !align_mask; - let needed = aligned + size_of::(); - self.block_bytes - .reserve(needed.saturating_sub(self.block_bytes.len())); - // Zero-fill to `needed`; bytes in [aligned, needed) are immediately - // overwritten by the BlockHeader assignment below. - self.block_bytes.resize(needed, 0); - - let hdr = self.get_block_header_at(aligned); - *hdr = BlockHeader { + ) -> Result<(), parser::Error> { + self.append_block_header(BlockHeader { block_type, _pad: [0; 3], flags, data, n_lines: 0, - }; + })?; Ok(()) } - pub fn enter_child_containers(&mut self, count: u32) -> Result<(), AllocError> { + pub fn enter_child_containers(&mut self, count: u32) -> Result<(), parser::Error> { let mut i: u32 = self.n_containers - count; while i < self.n_containers { // Capture the container fields before calling &mut self methods. @@ -65,6 +55,8 @@ impl Parser<'_> { } else if ch == b'-' || ch == b'+' || ch == b'*' { // Save opener position for later loose-list patching let align_mask_: usize = align_of::() - 1; + // In range: every `block_bytes` grower enforces + // `check_block_bytes_len`, which leaves alignment headroom. self.containers[idx].block_byte_off = u32::try_from((self.block_bytes.len() + align_mask_) & !align_mask_).unwrap(); // Unordered list + list item @@ -81,6 +73,8 @@ impl Parser<'_> { } else if ch == b'.' || ch == b')' { // Save opener position for later loose-list patching let align_mask_: usize = align_of::() - 1; + // In range: every `block_bytes` grower enforces + // `check_block_bytes_len`, which leaves alignment headroom. self.containers[idx].block_byte_off = u32::try_from((self.block_bytes.len() + align_mask_) & !align_mask_).unwrap(); // Ordered list + list item @@ -100,7 +94,7 @@ impl Parser<'_> { Ok(()) } - pub fn leave_child_containers(&mut self, keep: u32) -> Result<(), AllocError> { + pub fn leave_child_containers(&mut self, keep: u32) -> Result<(), parser::Error> { while self.n_containers > keep { self.n_containers -= 1; // Capture the container fields before calling &mut self methods. diff --git a/src/md/html_renderer.rs b/src/md/html_renderer.rs index 6a5f1c99911e..8fb7cea1edd0 100644 --- a/src/md/html_renderer.rs +++ b/src/md/html_renderer.rs @@ -4,6 +4,7 @@ use bun_core::strings; use crate::RenderOptions; use crate::helpers; +use crate::output::{OutputBuffer, try_extend, try_push}; use crate::types; use crate::types::{BlockType, JsResult, Renderer, RendererImpl, SpanDetail, SpanType, TextType}; @@ -21,36 +22,6 @@ pub(crate) struct HtmlRenderer<'src> { pub heading_tracker: helpers::HeadingIdTracker, } -pub struct OutputBuffer { - pub list: Vec, - // allocator dropped — non-AST crate uses global mimalloc - pub oom: bool, -} - -impl OutputBuffer { - fn write(&mut self, data: &[u8]) { - if self.oom { - return; - } - if self.list.try_reserve(data.len()).is_err() { - self.oom = true; - return; - } - self.list.extend_from_slice(data); - } - - fn write_byte(&mut self, b: u8) { - if self.oom { - return; - } - if self.list.try_reserve(1).is_err() { - self.oom = true; - return; - } - self.list.push(b); - } -} - impl<'src> HtmlRenderer<'src> { pub(crate) fn init(src_text: &'src [u8], render_opts: RenderOptions) -> HtmlRenderer<'src> { HtmlRenderer { @@ -426,11 +397,7 @@ impl<'src> HtmlRenderer<'src> { pub(crate) fn write(&mut self, data: &[u8]) { if self.heading_tracker.in_heading { - if self.heading_buf.try_reserve(data.len()).is_err() { - self.out.oom = true; - return; - } - self.heading_buf.extend_from_slice(data); + try_extend(&mut self.out.oom, &mut self.heading_buf, data); } else { self.out.write(data); } @@ -438,11 +405,7 @@ impl<'src> HtmlRenderer<'src> { fn write_byte(&mut self, b: u8) { if self.heading_tracker.in_heading { - if self.heading_buf.try_reserve(1).is_err() { - self.out.oom = true; - return; - } - self.heading_buf.push(b); + try_push(&mut self.out.oom, &mut self.heading_buf, b); } else { self.out.write_byte(b); } @@ -473,11 +436,7 @@ impl<'src> HtmlRenderer<'src> { if self.heading_tracker.in_heading { let items = self.heading_buf.as_slice(); if !items.is_empty() && items[items.len() - 1] != b'\n' { - if self.heading_buf.try_reserve(1).is_err() { - self.out.oom = true; - return; - } - self.heading_buf.push(b'\n'); + try_push(&mut self.out.oom, &mut self.heading_buf, b'\n'); } } else { let items = self.out.list.as_slice(); diff --git a/src/md/lib.rs b/src/md/lib.rs index 861b7657c470..df97e17a8dce 100644 --- a/src/md/lib.rs +++ b/src/md/lib.rs @@ -11,6 +11,7 @@ pub mod html_renderer; pub mod inlines; pub mod line_analysis; pub mod links; +pub mod output; pub mod parser; pub mod ref_defs; pub mod render_blocks; diff --git a/src/md/line_analysis.rs b/src/md/line_analysis.rs index 9dc466c0e6cb..30f7c1fb3960 100644 --- a/src/md/line_analysis.rs +++ b/src/md/line_analysis.rs @@ -1,8 +1,13 @@ use super::helpers; -use super::parser::Parser; +use super::parser::{self, Parser}; use super::types; use super::types::{Align, Container, OFF}; +/// ` { return 4; } - // Type 5: , + pub oom: bool, +} + +impl OutputBuffer { + pub(crate) fn write(&mut self, data: &[u8]) { + if self.oom { + return; + } + if self.list.try_reserve(data.len()).is_err() { + self.oom = true; + return; + } + self.list.extend_from_slice(data); + } + + pub(crate) fn write_byte(&mut self, b: u8) { + if self.oom { + return; + } + if self.list.try_reserve(1).is_err() { + self.oom = true; + return; + } + self.list.push(b); + } +} + +/// Grow `buf` by `data` without aborting on allocation failure; the failure +/// is recorded in `oom` and further writes become no-ops. +pub(crate) fn try_extend(oom: &mut bool, buf: &mut Vec, data: &[u8]) { + if *oom { + return; + } + if buf.try_reserve(data.len()).is_err() { + *oom = true; + return; + } + buf.extend_from_slice(data); +} + +/// [`try_extend`] for a single element. +pub(crate) fn try_push(oom: &mut bool, vec: &mut Vec, value: T) { + if *oom { + return; + } + if vec.try_reserve(1).is_err() { + *oom = true; + return; + } + vec.push(value); +} diff --git a/src/md/parser.rs b/src/md/parser.rs index a9ddceb87a15..40d651918e1b 100644 --- a/src/md/parser.rs +++ b/src/md/parser.rs @@ -2,6 +2,7 @@ use core::cell::Cell; use core::ffi::c_void; +use core::sync::atomic::{AtomicUsize, Ordering}; use bun_collections::bit_set::{ArrayBitSet, num_masks_for}; @@ -130,7 +131,7 @@ impl Default for BlockHeader { } /// `Parser`'s error type: the union of `{ OutOfMemory, JSError, JSTerminated }` -/// with the parser-specific `{ StackOverflow, InputTooLarge }`. +/// with the parser-specific `{ StackOverflow, InputTooLarge, TooManyBlocks }`. // (`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; @@ -141,9 +142,12 @@ 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. + /// The input is longer than [`MAX_INPUT_LEN`], so the parser's `u32` + /// offset arithmetic cannot address it. InputTooLarge, + /// The document needs more than [`MAX_BLOCK_BYTES`] of block metadata, + /// so the parser's `u32` block offsets cannot address it. + TooManyBlocks, } bun_core::oom_from_alloc!(ParserError); @@ -154,12 +158,69 @@ impl From 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. +/// The longest `OFF`-typed fixed lookahead the parser performs from an +/// in-bounds offset: the `() + align_of::()); + +// The headroom proof: a buffer filled to the cap can still be aligned up and +// take one more header without leaving `OFF` range. +const _: () = assert!( + ((MAX_BLOCK_BYTES + (align_of::() - 1)) & !(align_of::() - 1)) + + size_of::() + <= OFF::MAX as usize +); + +/// The runtime block-metadata cap checked by [`check_block_bytes_len`]: +/// always [`MAX_BLOCK_BYTES`] outside of tests, shrinkable only through +/// [`set_max_block_bytes_for_testing`]. +static BLOCK_BYTES_LIMIT: AtomicUsize = AtomicUsize::new(MAX_BLOCK_BYTES); + +/// `bun:internal-for-testing` (`setMaxMarkdownBlockBytesForTesting`): shrink +/// the block-metadata cap so the `TooManyBlocks` path is reachable without +/// allocating 4 GiB of headers. The cap can only be lowered, never raised +/// past [`MAX_BLOCK_BYTES`]. Returns the previous value so callers can +/// restore it. +pub fn set_max_block_bytes_for_testing(limit: usize) -> usize { + BLOCK_BYTES_LIMIT.swap(limit.min(MAX_BLOCK_BYTES), Ordering::Relaxed) +} + +/// Rejects growing `block_bytes` to `needed` bytes once the parser's u32 +/// block offsets could no longer address it. Every site that grows the +/// buffer (`append_block_header`, `end_current_block`) checks this before +/// appending. +#[inline] +pub(crate) fn check_block_bytes_len(needed: usize) -> Result<(), ParserError> { + if needed > BLOCK_BYTES_LIMIT.load(Ordering::Relaxed) { + return Err(ParserError::TooManyBlocks); + } + Ok(()) +} + +/// Callers that size anything from the input length must reject oversized +/// inputs with this before allocating. #[inline] pub(crate) fn input_size(text: &[u8]) -> Result { - OFF::try_from(text.len()).map_err(|_| ParserError::InputTooLarge) + if text.len() > MAX_INPUT_LEN { + return Err(ParserError::InputTooLarge); + } + Ok(text.len() as OFF) } impl<'a> Parser<'a> { @@ -185,6 +246,26 @@ impl<'a> Parser<'a> { self.get_block_header_at(off) } + /// Appends one aligned `BlockHeader` to `block_bytes` and returns its + /// byte offset. This is the only way a header is added, so the + /// block-metadata cap cannot be forgotten by a new caller. + pub(crate) fn append_block_header( + &mut self, + header: BlockHeader, + ) -> Result { + let align_mask: usize = align_of::() - 1; + let aligned = (self.block_bytes.len() + align_mask) & !align_mask; + let needed = aligned + size_of::(); + check_block_bytes_len(needed)?; + self.block_bytes + .reserve(needed.saturating_sub(self.block_bytes.len())); + // Zero-fill to `needed`; bytes in [aligned, needed) are immediately + // overwritten by the header write below. + self.block_bytes.resize(needed, 0); + *self.get_block_header_at(aligned) = header; + Ok(aligned) + } + fn init(text: &'a [u8], flags: Flags, rend: Renderer<'a>) -> Result, ParserError> { let size = input_size(text)?; let mut p = Parser { diff --git a/src/runtime/api/MarkdownObject.rs b/src/runtime/api/MarkdownObject.rs index 089dba2210c9..22855563fbd5 100644 --- a/src/runtime/api/MarkdownObject.rs +++ b/src/runtime/api/MarkdownObject.rs @@ -9,7 +9,7 @@ use bun_jsc::{ // thin mod-decl shim, so alias the `root` module (which re-exports BlockType, // SpanType, TextType, SpanDetail, Renderer, helpers, types, ansi, …) as `md`. use crate::node::StringOrBuffer; -use bun_md::parser::ParserError; +use bun_md::parser::{MAX_INPUT_LEN, ParserError}; use bun_md::root as md; // `bun_core::String::create_utf8_for_js` lives in `bun_jsc::bun_string_jsc` @@ -54,11 +54,21 @@ fn parser_err_to_js( ParserError::InputTooLarge => global_this.throw_range_error( input_len as i64, RangeErrorOptions { - max: md::types::OFF::MAX as i64, + max: MAX_INPUT_LEN as i64, field_name: b"input.byteLength", ..Default::default() }, ), + // The document, not the input length, overflowed the parser's u32 + // block offsets, so an `input.byteLength` bound would be misleading. + ParserError::TooManyBlocks => global_this + .err( + bun_jsc::ErrCode::OUT_OF_RANGE, + format_args!( + "markdown input requires more block metadata than the parser can address (4 GiB)" + ), + ) + .throw(), } } @@ -99,6 +109,26 @@ pub(crate) fn create(global_this: &JSGlobalObject) -> JSValue { ) } +/// `bun:internal-for-testing`'s `setMaxMarkdownBlockBytesForTesting(limit)`: +/// shrink the parser's block-metadata cap so its `TooManyBlocks` error is +/// testable without 4 GiB of input. Returns the previous limit. +#[bun_jsc::host_fn] +pub(crate) fn set_max_markdown_block_bytes_for_testing( + global_this: &JSGlobalObject, + callframe: &CallFrame, +) -> JsResult { + let [limit_value] = callframe.arguments_as_array::<1>(); + if !limit_value.is_number() { + return Err(global_this.throw_invalid_arguments(format_args!( + "setMaxMarkdownBlockBytesForTesting expects a number" + ))); + } + let limit = usize::try_from(limit_value.coerce_to_int64(global_this)?.max(0)) + .expect("non-negative i64 fits usize"); + let prev = bun_md::parser::set_max_block_bytes_for_testing(limit); + Ok(JSValue::js_number(prev as f64)) +} + /// `Bun.markdown.ansi(text, theme?)` — render markdown to an ANSI-colored /// terminal string. `theme` is an optional object: `{ colors?, hyperlinks?, /// light?, columns? }`. By default colors are enabled, hyperlinks are diff --git a/test/js/bun/md/md-edge-cases.test.ts b/test/js/bun/md/md-edge-cases.test.ts index 221d716ab5d5..1361551514d3 100644 --- a/test/js/bun/md/md-edge-cases.test.ts +++ b/test/js/bun/md/md-edge-cases.test.ts @@ -1093,7 +1093,10 @@ describe("pathological reference definition inputs", () => { env: bunEnv, stdout: "pipe", stderr: "pipe", - timeout: 30_000, + // 220k lines through a debug+ASAN child run close to 30s on a loaded + // runner, which made this the flakiest test in the file; the hard stop + // only has to stay under the test's own 90s timeout. + timeout: 75_000, killSignal: "SIGKILL", }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); @@ -1190,21 +1193,24 @@ 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 () => { +describe("inputs the parser cannot address", () => { + // The parser addresses its input with u32 offsets and probes up to 9 bytes + // past an offset (the ` { const script = ` - let big; + let big, boundary; try { big = new Uint8Array(2 ** 32); + boundary = new Uint8Array(2 ** 32 - 1); } catch { console.log(JSON.stringify("SKIP")); process.exit(0); @@ -1214,6 +1220,9 @@ describe("inputs of 2^32 bytes or more", () => { () => Bun.markdown.ansi(big), () => Bun.markdown.render(big, {}), () => Bun.markdown.react(big, undefined, { reactVersion: 18 }), + // One past the accepted maximum of 4294967286 from the other side: + // the largest allocatable length that must still be rejected. + () => Bun.markdown.html(boundary), ]; const results = []; for (const run of runs) { @@ -1236,11 +1245,70 @@ describe("inputs of 2^32 bytes or more", () => { 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"'), - ); + const message = (received: number) => + `RangeError | ERR_OUT_OF_RANGE | The value of "input.byteLength" is out of range. It must be <= 4294967286. Received ${received}`; + expect([ + "SKIP", + [message(2 ** 32), message(2 ** 32), message(2 ** 32), message(2 ** 32), message(2 ** 32 - 1)], + ]).toContainEqual(JSON.parse(stdout.trim() || '"NO_OUTPUT"')); + expect(exitCode).toBe(0); + }, 30_000); +}); + +describe("documents whose block metadata the parser cannot address", () => { + // Block offsets are u32s too, so `block_bytes` (the flat buffer of block + // headers and verbatim-line records) is capped independently of the input + // length. Filling the real ~4 GiB cap needs ~256M blocks, so the child + // shrinks it through `setMaxMarkdownBlockBytesForTesting` + // (bun:internal-for-testing) and proves the exact boundary: a document + // whose metadata lands exactly on the cap renders, and one more block (or + // the container openers of a nested blockquote) raises the catchable + // RangeError that replaced the release-build integer-cast panic. + test("a document needing more block metadata than the cap throws a RangeError", async () => { + const script = ` + import { setMaxMarkdownBlockBytesForTesting } from "bun:internal-for-testing"; + // One single-line paragraph costs exactly one 16-byte BlockHeader plus + // one 12-byte VerbatimLine in block_bytes, with no alignment padding. + const PARAGRAPH_BYTES = 16 + 12; + const AT_LIMIT = 40; + const paragraphs = n => Array.from({ length: n }, (_, i) => "p" + i).join("\\n\\n"); + const nestedQuotes = Buffer.alloc(128, "> ").toString() + "deep"; + const render = input => { + try { + return typeof Bun.markdown.html(input); + } catch (e) { + return [e.constructor.name, e.code, e.message].join(" | "); + } + }; + const results = []; + const previous = setMaxMarkdownBlockBytesForTesting(AT_LIMIT * PARAGRAPH_BYTES); + try { + results.push(render(paragraphs(AT_LIMIT))); + results.push(render(paragraphs(AT_LIMIT + 1))); + results.push(render(nestedQuotes)); + } finally { + setMaxMarkdownBlockBytesForTesting(previous); + } + // The restore took: the same over-limit documents render again. + results.push(render(paragraphs(AT_LIMIT + 1)), render(nestedQuotes)); + console.log(JSON.stringify(results)); + `; + 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]); + const tooManyBlocks = + "RangeError | ERR_OUT_OF_RANGE | markdown input requires more block metadata than the parser can address (4 GiB)"; + expect(JSON.parse(stdout.trim() || '"NO_OUTPUT"')).toEqual([ + "string", + tooManyBlocks, + tooManyBlocks, + "string", + "string", + ]); expect(exitCode).toBe(0); }, 30_000); });