diff --git a/src/ast/expr.rs b/src/ast/expr.rs index 540558773b0..5766dfeedf4 100644 --- a/src/ast/expr.rs +++ b/src/ast/expr.rs @@ -782,22 +782,45 @@ impl Expr { // `ConstParamTy` (Op.rs owns the enum); pass at runtime here. Revisit once // `Code` gains `ConstParamTy` — call sites are a handful of literal ops. pub fn join_with_left_associative_op(op: Op::Code, a: Expr, b: Expr) -> Expr { - // "(a, b) op c" => "a, b op c" - if let Data::EBinary(mut comma) = a.data { - if comma.op == crate::OpCode::BinComma { - comma.right = Self::join_with_left_associative_op(op, comma.right, b); + Self::join_with_left_associative_op_with_check(op, a, b, bun_core::StackCheck::init()) + } + + fn join_with_left_associative_op_with_check( + op: Op::Code, + a: Expr, + b: Expr, + stack_check: bun_core::StackCheck, + ) -> Expr { + if stack_check.is_safe_to_recurse() { + // "(a, b) op c" => "a, b op c" + if let Data::EBinary(mut comma) = a.data { + if comma.op == crate::OpCode::BinComma { + comma.right = Self::join_with_left_associative_op_with_check( + op, + comma.right, + b, + stack_check, + ); + return a; + } } - } - // "a op (b op c)" => "(a op b) op c" - // "a op (b op (c op d))" => "((a op b) op c) op d" - if let Data::EBinary(binary) = b.data { - if binary.op == op { - return Self::join_with_left_associative_op( - op, - Self::join_with_left_associative_op(op, a, binary.left), - binary.right, - ); + // "a op (b op c)" => "(a op b) op c" + // "a op (b op (c op d))" => "((a op b) op c) op d" + if let Data::EBinary(binary) = b.data { + if binary.op == op { + return Self::join_with_left_associative_op_with_check( + op, + Self::join_with_left_associative_op_with_check( + op, + a, + binary.left, + stack_check, + ), + binary.right, + stack_check, + ); + } } } @@ -2845,6 +2868,13 @@ impl Data { } pub fn known_primitive(&self) -> PrimitiveType { + self.known_primitive_with_check(bun_core::StackCheck::init()) + } + + fn known_primitive_with_check(&self, stack_check: bun_core::StackCheck) -> PrimitiveType { + if !stack_check.is_safe_to_recurse() { + return PrimitiveType::Unknown; + } match self { Data::EBigInt(_) => PrimitiveType::Bigint, Data::EBoolean(_) | Data::EBranchBoolean(_) => PrimitiveType::Boolean, @@ -2859,7 +2889,10 @@ impl Data { PrimitiveType::Unknown } } - Data::EIf(e_if) => e_if.yes.data.merge_known_primitive(&e_if.no.data), + Data::EIf(e_if) => e_if + .yes + .data + .merge_known_primitive_with_check(&e_if.no.data, stack_check), Data::EBinary(binary) => 'brk: { match binary.op { crate::OpCode::BinStrictEq @@ -2873,12 +2906,15 @@ impl Data { | crate::OpCode::BinInstanceof | crate::OpCode::BinIn => break 'brk PrimitiveType::Boolean, crate::OpCode::BinLogicalOr | crate::OpCode::BinLogicalAnd => { - break 'brk binary.left.data.merge_known_primitive(&binary.right.data); + break 'brk binary + .left + .data + .merge_known_primitive_with_check(&binary.right.data, stack_check); } crate::OpCode::BinNullishCoalescing => { - let left = binary.left.data.known_primitive(); - let right = binary.right.data.known_primitive(); + let left = binary.left.data.known_primitive_with_check(stack_check); + let right = binary.right.data.known_primitive_with_check(stack_check); if left == PrimitiveType::Null || left == PrimitiveType::Undefined { break 'brk right; } @@ -2895,8 +2931,8 @@ impl Data { } crate::OpCode::BinAdd => { - let left = binary.left.data.known_primitive(); - let right = binary.right.data.known_primitive(); + let left = binary.left.data.known_primitive_with_check(stack_check); + let right = binary.right.data.known_primitive_with_check(stack_check); if left == PrimitiveType::String || right == PrimitiveType::String { break 'brk PrimitiveType::String; @@ -2945,7 +2981,7 @@ impl Data { | crate::OpCode::BinUShrAssign => break 'brk PrimitiveType::Mixed, // Can be number or bigint (or an exception) crate::OpCode::BinAssign | crate::OpCode::BinComma => { - break 'brk binary.right.data.known_primitive(); + break 'brk binary.right.data.known_primitive_with_check(stack_check); } _ => {} @@ -2960,7 +2996,7 @@ impl Data { crate::OpCode::UnNot | crate::OpCode::UnDelete => PrimitiveType::Boolean, crate::OpCode::UnPos => PrimitiveType::Number, // Cannot be bigint because that throws an exception crate::OpCode::UnNeg | crate::OpCode::UnCpl => { - match unary.value.data.known_primitive() { + match unary.value.data.known_primitive_with_check(stack_check) { PrimitiveType::Bigint => PrimitiveType::Bigint, PrimitiveType::Unknown | PrimitiveType::Mixed => PrimitiveType::Mixed, _ => PrimitiveType::Number, // Can be number or bigint @@ -2974,14 +3010,27 @@ impl Data { _ => PrimitiveType::Unknown, }, - Data::EInlinedEnum(inlined) => inlined.value.data.known_primitive(), + Data::EInlinedEnum(inlined) => { + inlined.value.data.known_primitive_with_check(stack_check) + } _ => PrimitiveType::Unknown, } } pub fn merge_known_primitive(&self, rhs: &Data) -> PrimitiveType { - PrimitiveType::merge(self.known_primitive(), rhs.known_primitive()) + self.merge_known_primitive_with_check(rhs, bun_core::StackCheck::init()) + } + + fn merge_known_primitive_with_check( + &self, + rhs: &Data, + stack_check: bun_core::StackCheck, + ) -> PrimitiveType { + PrimitiveType::merge( + self.known_primitive_with_check(stack_check), + rhs.known_primitive_with_check(stack_check), + ) } /// Returns true if the result of the "typeof" operator on this expression is diff --git a/src/js_parser/p.rs b/src/js_parser/p.rs index 81fe2c57e8f..f6a96b5d439 100644 --- a/src/js_parser/p.rs +++ b/src/js_parser/p.rs @@ -356,6 +356,8 @@ pub struct P<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> { /// hard-overflow. Same `MAX_STMT_DEPTH` rationale as `interchange/json.rs`. pub parse_stmt_depth: u32, + pub reported_stack_overflow: core::cell::Cell, + /// When this flag is enabled, we attempt to fold all expressions that /// TypeScript would consider to be "constant expressions". This flag is /// enabled inside each enum body block since TypeScript requires numeric @@ -776,6 +778,15 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O unsafe { &mut *self.log.as_ptr() } } + pub fn report_stack_overflow(&self, loc: bun_ast::Loc) { + if self.reported_stack_overflow.get() { + return; + } + self.reported_stack_overflow.set(true); + self.log() + .add_error(Some(self.source), loc, b"Maximum call stack size exceeded"); + } + /// Safe mutable projection of `nearest_stmt_list`. /// /// The pointer targets a `ListManaged` living on a parent @@ -2518,6 +2529,10 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O replacement: Expr, replacement_can_be_removed: bool, ) -> Substitution { + if !self.stack_check.is_safe_to_recurse() || self.reported_stack_overflow.get() { + self.report_stack_overflow(expr.loc); + return Substitution::Failure(expr); + } // Zig matched on `expr.data` (a tagged union of `*E.*`) and mutated through // the captured pointer. `ExprData` is `Copy`; matching by value yields owned // `StoreRef` copies whose `DerefMut` writes to the same arena slot, @@ -3582,6 +3597,15 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O kind: js_ast::scope::Kind, loc: bun_ast::Loc, ) -> Result<(), bun_core::Error> { + if self.reported_stack_overflow.get() { + while let Some((head, rest)) = self.scope_order_to_visit.split_first() { + if head.loc.start == loc.start && head.scope_ref().kind == kind { + break; + } + self.scope_order_to_visit = rest; + } + } + let order = self.next_scope_in_order_for_visit_pass(); // Sanity-check that the scopes generated by the first and second passes match @@ -5854,6 +5878,10 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } fn expr_can_be_removed_if_unused_without_dce_check(&mut self, expr: &Expr) -> bool { + if !self.stack_check.is_safe_to_recurse() || self.reported_stack_overflow.get() { + self.report_stack_overflow(expr.loc); + return false; + } match &expr.data { js_ast::ExprData::ENull(_) | js_ast::ExprData::EUndefined(_) @@ -9212,6 +9240,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O log, stack_check: bun_core::StackCheck::init(), parse_stmt_depth: 0, + reported_stack_overflow: core::cell::Cell::new(false), arena, then_catch_chain: ThenCatchChain { next_target: null_expr_data(), diff --git a/src/js_parser/parse/mod.rs b/src/js_parser/parse/mod.rs index df234d843b3..5719a2acba2 100644 --- a/src/js_parser/parse/mod.rs +++ b/src/js_parser/parse/mod.rs @@ -971,6 +971,9 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O pub fn parse_binding(&mut self, opts: ParseBindingOptions) -> Result { let p = self; + if !p.stack_check.is_safe_to_recurse() { + return Err(err!("StackOverflow")); + } let loc = p.lexer.loc(); match p.lexer.token { diff --git a/src/js_parser/scan/scan_side_effects.rs b/src/js_parser/scan/scan_side_effects.rs index 634a679d4a2..1dcd76b4d1e 100644 --- a/src/js_parser/scan/scan_side_effects.rs +++ b/src/js_parser/scan/scan_side_effects.rs @@ -147,6 +147,10 @@ impl SideEffects { if !p.options.features.dead_code_elimination { return Some(expr); } + if !p.stack_check.is_safe_to_recurse() || p.reported_stack_overflow.get() { + p.report_stack_overflow(expr.loc); + return Some(expr); + } // PORT NOTE: `Expr`/`ExprData`/`StoreRef<_>` are all `Copy`. We match on // `expr.data` *by value* so `expr` itself is never borrowed across a // recursive `simplify_unused_expr(p, ..)` call. Mutations to boxed @@ -304,8 +308,12 @@ impl SideEffects { | Op::Code::BinGt | Op::Code::BinLe | Op::Code::BinGe => { - if Self::is_primitive_with_side_effects(&bin.left.data) - && Self::is_primitive_with_side_effects(&bin.right.data) + if Self::is_primitive_with_side_effects(p, bin.left.loc, &bin.left.data) + && Self::is_primitive_with_side_effects( + p, + bin.right.loc, + &bin.right.data, + ) { let left = bin.left; let right = bin.right; @@ -728,7 +736,15 @@ impl SideEffects { } } - pub fn is_primitive_with_side_effects(data: &ExprData) -> bool { + pub fn is_primitive_with_side_effects<'a, const TS: bool, const SCAN: bool>( + p: &P<'a, TS, SCAN>, + loc: bun_ast::Loc, + data: &ExprData, + ) -> bool { + if !p.stack_check.is_safe_to_recurse() { + p.report_stack_overflow(loc); + return false; + } match data { ExprData::ENull(_) | ExprData::EUndefined(_) @@ -773,17 +789,17 @@ impl SideEffects { Op::Code::BinLogicalAnd | Op::Code::BinLogicalOr | Op::Code::BinNullishCoalescing | Op::Code::BinLogicalAndAssign | Op::Code::BinLogicalOrAssign | Op::Code::BinNullishCoalescingAssign => { - Self::is_primitive_with_side_effects(&e.left.data) - && Self::is_primitive_with_side_effects(&e.right.data) + Self::is_primitive_with_side_effects(p, e.left.loc, &e.left.data) + && Self::is_primitive_with_side_effects(p, e.right.loc, &e.right.data) } Op::Code::BinComma => { - Self::is_primitive_with_side_effects(&e.right.data) + Self::is_primitive_with_side_effects(p, e.right.loc, &e.right.data) } _ => false, }, ExprData::EIf(e) => { - Self::is_primitive_with_side_effects(&e.yes.data) - && Self::is_primitive_with_side_effects(&e.no.data) + Self::is_primitive_with_side_effects(p, e.yes.loc, &e.yes.data) + && Self::is_primitive_with_side_effects(p, e.no.loc, &e.no.data) } _ => false, } @@ -883,6 +899,10 @@ impl SideEffects { if !p.options.features.dead_code_elimination { return Result::default(); } + if !p.stack_check.is_safe_to_recurse() { + p.report_stack_overflow(bun_ast::Loc::EMPTY); + return Result::default(); + } match exp { ExprData::ENull(_) | ExprData::EUndefined(_) => Result { value: false, diff --git a/src/js_parser/visit/mod.rs b/src/js_parser/visit/mod.rs index 9b419a31122..4f9235b9852 100644 --- a/src/js_parser/visit/mod.rs +++ b/src/js_parser/visit/mod.rs @@ -599,6 +599,10 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O binding: BindingNodeIndex, mut duplicate_arg_check: Option<&mut StringVoidMap>, ) { + if !self.stack_check.is_safe_to_recurse() { + self.report_stack_overflow(binding.loc); + return; + } match binding.data { BData::BMissing(_) => {} BData::BIdentifier(bind) => { diff --git a/src/js_parser/visit/visit_expr.rs b/src/js_parser/visit/visit_expr.rs index 6dfed1e0093..7bdec3f8e7a 100644 --- a/src/js_parser/visit/visit_expr.rs +++ b/src/js_parser/visit/visit_expr.rs @@ -46,6 +46,11 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } pub fn visit_expr_in_out(&mut self, e: &mut Expr, in_: ExprIn) { + if !self.stack_check.is_safe_to_recurse() || self.reported_stack_overflow.get() { + self.report_stack_overflow(e.loc); + return; + } + if in_.assign_target != js_ast::AssignTarget::None && !self.is_valid_assignment_target(e) { self.log() .add_error(Some(self.source), e.loc, b"Invalid assignment target"); diff --git a/src/js_printer/lib.rs b/src/js_printer/lib.rs index 987a456b497..fde4506f118 100644 --- a/src/js_printer/lib.rs +++ b/src/js_printer/lib.rs @@ -1758,6 +1758,9 @@ pub mod __gated_printer { pub binary_expression_stack: Vec>, + pub stack_check: bun_core::StackCheck, + pub stack_overflowed: bool, + pub was_lazy_export: bool, // PORT NOTE: Zig used `if (!may_have_module_info) void else ?*ModuleInfo` — in Rust we always // carry the Option and gate at call sites with MAY_HAVE_MODULE_INFO. @@ -3259,7 +3262,19 @@ pub mod __gated_printer { } } + pub fn check_stack_overflow(&self) -> Result<(), bun_core::Error> { + if self.stack_overflowed { + return Err(bun_core::err!("StackOverflow")); + } + Ok(()) + } + pub fn print_expr(&mut self, expr: Expr, level: Level, in_flags: ExprFlagSet) { + if !self.stack_check.is_safe_to_recurse() { + self.stack_overflowed = true; + return; + } + let mut flags = in_flags; match &expr.data { @@ -4952,6 +4967,11 @@ pub mod __gated_printer { } pub fn print_binding(&mut self, binding: Binding, tlm: TopLevelAndIsExport) { + if !self.stack_check.is_safe_to_recurse() { + self.stack_overflowed = true; + return; + } + match &binding.data { BindingData::BMissing(_) => {} BindingData::BIdentifier(b) => { @@ -7082,6 +7102,8 @@ pub mod __gated_printer { symbol_counter: 0, temporary_bindings: Vec::new(), binary_expression_stack: Vec::new(), + stack_check: bun_core::StackCheck::init(), + stack_overflowed: false, was_lazy_export: false, module_info: None, }; @@ -8150,6 +8172,7 @@ pub fn print_ast<'a, W: WriterTrait, const ASCII_ONLY: bool, const GENERATE_SOUR printer.print_semicolon_if_needed(); } } + printer.check_stack_overflow()?; let have_module_info = PrinterType::::MAY_HAVE_MODULE_INFO && printer.module_info.is_some(); @@ -8247,6 +8270,7 @@ pub fn print_json( printer.binary_expression_stack = Vec::new(); printer.print_expr(expr, js_ast::op::Level::Lowest, ExprFlagSet::empty()); + printer.check_stack_overflow()?; printer.writer.get_error()?; printer.writer.done()?; @@ -8395,6 +8419,10 @@ pub fn print_with_writer_and_platform< } } + if let Err(err) = printer.check_stack_overflow() { + return PrintResult::Err(err); + } + if let Err(err) = printer.writer.done() { // In bundle_v2, this is backed by an arena, but incremental uses // `dev.allocator` for this buffer, so it must be freed. @@ -8475,6 +8503,7 @@ pub fn print_common_js< printer.print_semicolon_if_needed(); } } + printer.check_stack_overflow()?; // Add a couple extra newlines at the end printer.writer.print_slice(b"\n\n"); diff --git a/test/bundler/transpiler/transpiler.test.js b/test/bundler/transpiler/transpiler.test.js index 92c354d3422..a0db3393bd5 100644 --- a/test/bundler/transpiler/transpiler.test.js +++ b/test/bundler/transpiler/transpiler.test.js @@ -3916,6 +3916,81 @@ it("Bun.Transpiler.transform stack overflows", async () => { expect(async () => await transpiler.transform(code)).toThrow(`Maximum call stack size exceeded`); }); +it("deeply nested expressions error instead of crashing the process", () => { + const script = ` + const repeat = (fill, count) => Buffer.alloc(fill.length * count, fill).toString(); + const shapes = [ + n => repeat("- ", n) + "1", + n => repeat("f(", n) + "1" + repeat(")", n), + n => repeat("[", n) + "1" + repeat("]", n), + n => "void " + repeat("- ", n) + "1", + n => repeat("[", n) + "() => 1" + repeat("]", n) + ";{ let x; }", + n => repeat("[", n) + "1" + repeat("]", n) + "; someLongIdentifier + anotherIdentifier;", + n => "void ((x" + repeat(" ?? x", n) + ") < 1)", + n => "(a" + repeat(" && a", n) + ") == 1;", + n => "f() ? 1 : g()" + repeat(" || g()", n) + ";", + n => "let " + repeat("[", n) + "x" + repeat("]", n) + " = y;", + ]; + const minifyShapes = [ + n => "function f(){let x = 1; return a" + repeat(" && a", n) + " && x}", + n => + "function f(){function g(){return x}" + + repeat("[", n) + + "1" + + repeat("]", n) + + ";let x = 1;return " + + repeat("a", 500) + + ";}", + ]; + const check = (transpiler, src) => { + try { + transpiler.transformSync(src); + } catch (e) { + if (!/Maximum call stack size exceeded|StackOverflow/.test(String(e?.message))) throw e; + } + }; + for (const shape of shapes) { + for (const n of [4000, 20000, 100000]) { + check(new Bun.Transpiler({ loader: "js" }), shape(n)); + } + } + for (const shape of minifyShapes) { + for (const n of [4000, 20000, 100000]) { + check(new Bun.Transpiler({ loader: "js", minify: true }), shape(n)); + } + } + console.log("depth-ok"); + `; + const { stdout, exitCode, signalCode } = Bun.spawnSync({ + cmd: [bunExe(), "-e", script], + stdout: "pipe", + stderr: "pipe", + env: bunEnv, + }); + + expect(stdout.toString()).toBe("depth-ok\n"); + expect([exitCode, signalCode ?? undefined]).toEqual([0, undefined]); +}, 60_000); + +it("running a file with deeply nested unary operators does not crash the process", () => { + const code = Buffer.alloc(2 * 4000, "- ").toString() + "1"; + const { exitCode, signalCode } = Bun.spawnSync({ + cmd: [bunExe(), "-e", code], + stdout: "pipe", + stderr: "pipe", + env: bunEnv, + }); + + expect(signalCode ?? undefined).toBeUndefined(); + expect([0, 1]).toContain(exitCode); +}); + +it("does not duplicate the branch when simplifying an unused ternary with a comma test", () => { + const transpiler = new Bun.Transpiler({ loader: "js" }); + expect(transpiler.transformSync("(f(), g()) ? 1 : h();").trim()).toBe("f(), g() || h();"); + expect(transpiler.transformSync("(f(), g()) ? h() : 1;").trim()).toBe("f(), g() && h();"); +}); + describe("arrow function parsing after const declaration (scope mismatch bug)", () => { const transpiler = new Bun.Transpiler({ loader: "tsx" });