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
This commit is contained in:
Max Murphy
2026-10-31 16:41:24 +01:00
parent 67e39faeea
commit 81aee9e075
2 changed files with 48 additions and 2 deletions
+29 -2
View File
@@ -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<usize> {
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<Value> {
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);
}
}
+19
View File
@@ -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();