From 81aee9e075836cddf14ca63aba0ea082fb1d7498 Mon Sep 17 00:00:00 2001 From: Max Murphy Date: Sat, 31 Oct 2026 16:41:24 +0100 Subject: [PATCH] calc: form the MAP result size in u64 so wasm32 cannot wrap the cap (#2165) `broadcast_len`, the MAX_CELLS guard shared by MAP, the binary operators, TEXT and array IF, multiplied `rows` and `cols` as `usize`. On wasm32 a broadcast such as 65536 x 65536 (2^32 cells) wraps that product to 0 and slips past the cap, after which the allocation and loop run far past MAX_CELLS. The product is now formed in u64 and converted back only after the cap check, so the guard means the same on every target. `single`, the per-cell scalar check in MAP and MAKEARRAY, compared `rows * cols` with 1. A constructed array stores exactly `rows * cols` values, so it now tests `data.len()` directly: the same answer without an unchecked product. Tests cover MAX_CELLS exactly, one cell over, the 2^32 pair that wraps to zero on a 32-bit usize, a full column x row (2^34), and MAP itself just over the cap and at 16384 x 16384. Closes #2165 --- crates/calc/src/eval/array.rs | 31 +++++++++++++++++++++++++++++-- crates/calc/src/tests.rs | 19 +++++++++++++++++++ 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/crates/calc/src/eval/array.rs b/crates/calc/src/eval/array.rs index ab6ac1b06..67662c83c 100644 --- a/crates/calc/src/eval/array.rs +++ b/crates/calc/src/eval/array.rs @@ -89,8 +89,14 @@ pub fn zip2(a: &Value, b: &Value, f: impl Fn(&Value, &Value) -> Value) -> Value /// Cell count of a `rows`×`cols` broadcast result, or `None` when it would exceed [`MAX_CELLS`]. /// Each operand passes its own size cap, but their broadcast product can be far larger /// (`SEQUENCE(1048576)*SEQUENCE(1,16384)` is 17 billion cells), so it is checked before allocating. +/// The product is formed in `u64`: on wasm32 `usize` is 32 bits, where a `usize` product above +/// `u32::MAX` would wrap (2^32 cells → 0) and slip past the cap. pub(super) fn broadcast_len(rows: usize, cols: usize) -> Option { - rows.checked_mul(cols).filter(|&n| n as u64 <= MAX_CELLS) + let cells = u64::try_from(rows).ok()?.checked_mul(u64::try_from(cols).ok()?)?; + if cells > MAX_CELLS { + return None; + } + usize::try_from(cells).ok() } fn broadcast_dim(a: usize, b: usize) -> usize { @@ -124,7 +130,7 @@ pub(super) fn zip3(c: &Array, t: &Value, f: &Value, g: impl Fn(&Value, &Value, & /// can't nest, so the whole helper gives `#CALC!`, as in Excel. pub(super) fn single(v: Value) -> Option { match v { - Value::Array(a) if a.rows * a.cols > 1 => None, + Value::Array(a) if a.data.len() > 1 => None, v => Some(v.scalar()), } } @@ -178,3 +184,24 @@ fn binary_scalar(op: BinOp, a: &Value, b: &Value, sys: gridcraft_core::DateSyste BinOp::Range | BinOp::Union | BinOp::Intersect => Value::Error(CellError::Value), } } + +#[cfg(test)] +mod tests { + use super::{MAX_CELLS, broadcast_len}; + + #[test] + fn broadcast_len_caps_at_max_cells() { + assert_eq!(broadcast_len(4_000, 4_000), Some(MAX_CELLS as usize)); + assert_eq!(broadcast_len(4_000, 4_001), None); + assert_eq!(broadcast_len(1, MAX_CELLS as usize), Some(MAX_CELLS as usize)); + } + + /// `65_536 * 65_536` is 2^32. Multiplied as `usize` on wasm32 it wraps to 0, which a + /// `> MAX_CELLS` test would let through; the guard must reject the true product. + #[test] + fn broadcast_len_rejects_a_product_that_wraps_to_zero_on_32_bits() { + assert_eq!(65_536u32.wrapping_mul(65_536), 0); + assert_eq!(broadcast_len(65_536, 65_536), None); + assert_eq!(broadcast_len(1_048_576, 16_384), None); + } +} diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index e2d62db66..89e6e7327 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -1251,6 +1251,25 @@ fn array_broadcast_result_is_capped() { assert_eq!(t.num("C3"), 5.0); } +/// MAP refuses a result past `MAX_CELLS` by its checked product, before allocating it (the +/// 2^32-cell wrap that a 32-bit `usize` product would turn into 0 is pinned in +/// `broadcast_len_rejects_a_product_that_wraps_to_zero_on_32_bits`; `SEQUENCE` tops out at +/// 16384 columns, so the widest product it can build is 16384 squared). +#[test] +fn map_caps_its_result_size() { + let mut t = T::new(); + for (at, formula) in + [("A1", "=MAP(SEQUENCE(4001),SEQUENCE(1,4000),LAMBDA(a,b,a))"), ("A2", "=MAP(SEQUENCE(16384),SEQUENCE(1,16384),LAMBDA(a,b,a))")] + { + t.set(at, formula); + assert_eq!(t.get(at), Value::Error(CellError::Num), "{formula}"); + } + // Within the cap the broadcast shape is unchanged. + t.set("A3", "=ROWS(MAP(SEQUENCE(3),SEQUENCE(1,2),LAMBDA(a,b,a+b)))"); + t.set("A4", "=COLUMNS(MAP(SEQUENCE(3),SEQUENCE(1,2),LAMBDA(a,b,a+b)))"); + assert_eq!((t.num("A3"), t.num("A4")), (3.0, 2.0)); +} + #[test] fn text_function() { let mut t = T::new();