Files
gridcraft/crates/calc/src/eval/array.rs
T
Max Murphy 81aee9e075 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
2026-10-31 16:41:24 +01:00

208 lines
7.7 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use super::{Array, BinOp, CellError, MAX_CELLS, Value, compare};
/// The aggregates that can fold a reference without building an array.
#[derive(Clone, Copy, PartialEq, Eq)]
pub(super) enum Agg {
Sum,
Count,
Average,
}
impl Agg {
pub(super) fn of(name: &str) -> Option<Agg> {
match name {
"SUM" => Some(Agg::Sum),
"COUNT" => Some(Agg::Count),
"AVERAGE" => Some(Agg::Average),
_ => None,
}
}
}
/// What a folded aggregate carries from one value to the next.
#[derive(Default)]
pub(super) struct Acc {
pub(super) sum: f64,
pub(super) count: u64,
}
impl Acc {
/// Takes a value found inside a reference: only numbers count. `Err` is an error value that
/// ends `SUM` and `AVERAGE`; `COUNT` skips errors.
pub(super) fn add_cell(&mut self, kind: Agg, v: &Value) -> Result<(), CellError> {
match v {
Value::Number(n) => {
self.sum += n;
self.count = self.count.saturating_add(1);
Ok(())
}
Value::Error(e) if kind != Agg::Count => Err(*e),
_ => Ok(()),
}
}
pub(super) fn finish(&self, kind: Agg) -> Value {
match kind {
Agg::Sum => Value::number(self.sum),
Agg::Count => Value::number(self.count as f64),
Agg::Average if self.count == 0 => Value::Error(CellError::Div0),
Agg::Average => Value::number(self.sum / self.count as f64),
}
}
}
// ---------------------------------------------------------------- operators
pub(super) fn map_unary(v: &Value, f: &dyn Fn(f64) -> Value, plus: bool, sys: gridcraft_core::DateSystem) -> Value {
match v {
Value::Array(a) => Value::from(Array { rows: a.rows, cols: a.cols, data: a.data.iter().map(|x| map_unary(x, f, plus, sys)).collect() }),
Value::Error(e) => Value::Error(*e),
Value::Text(_) if plus => v.clone(),
other => match other.to_number_in(sys) {
Ok(n) => f(n),
Err(e) => Value::Error(e),
},
}
}
/// Element-wise combination with Excel's broadcasting.
pub fn zip2(a: &Value, b: &Value, f: impl Fn(&Value, &Value) -> Value) -> Value {
match (a, b) {
(Value::Array(x), Value::Array(y)) => {
let rows = broadcast_dim(x.rows, y.rows);
let cols = broadcast_dim(x.cols, y.cols);
let Some(n) = broadcast_len(rows, cols) else { return Value::Error(CellError::Num) };
let mut data = Vec::with_capacity(n);
for r in 0..rows {
for c in 0..cols {
data.push(f(&x.get_broadcast(r, c), &y.get_broadcast(r, c)));
}
}
Value::from(Array { rows, cols, data })
}
(Value::Array(x), s) => Value::from(Array { rows: x.rows, cols: x.cols, data: x.data.iter().map(|v| f(v, s)).collect() }),
(s, Value::Array(y)) => Value::from(Array { rows: y.rows, cols: y.cols, data: y.data.iter().map(|v| f(s, v)).collect() }),
(x, y) => f(x, y),
}
}
/// 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> {
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 {
if a == 1 {
b
} else if b == 1 {
a
} else {
a.max(b)
}
}
pub(super) fn zip3(c: &Array, t: &Value, f: &Value, g: impl Fn(&Value, &Value, &Value) -> Value) -> Value {
let dims = |v: &Value| if let Value::Array(a) = v { (a.rows, a.cols) } else { (1, 1) };
let (tr, tc) = dims(t);
let (fr, fc) = dims(f);
let rows = broadcast_dim(broadcast_dim(c.rows, tr), fr);
let cols = broadcast_dim(broadcast_dim(c.cols, tc), fc);
let Some(n) = broadcast_len(rows, cols) else { return Value::Error(CellError::Num) };
let get = |v: &Value, r: usize, col: usize| if let Value::Array(a) = v { a.get_broadcast(r, col) } else { v.clone() };
let mut data = Vec::with_capacity(n);
for r in 0..rows {
for col in 0..cols {
data.push(g(&c.get_broadcast(r, col), &get(t, r, col), &get(f, r, col)));
}
}
Value::from(Array { rows, cols, data })
}
/// One cell of a LAMBDA helper's result, or `None` for an array of more than one value: arrays
/// 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.data.len() > 1 => None,
v => Some(v.scalar()),
}
}
pub fn binary_values(op: BinOp, x: &Value, y: &Value, sys: gridcraft_core::DateSystem) -> Value {
zip2(x, y, |a, b| binary_scalar(op, a, b, sys))
}
fn binary_scalar(op: BinOp, a: &Value, b: &Value, sys: gridcraft_core::DateSystem) -> Value {
if let Value::Error(e) = a {
return Value::Error(*e);
}
if let Value::Error(e) = b {
return Value::Error(*e);
}
let num = |f: fn(f64, f64) -> Value| match (a.to_number_in(sys), b.to_number_in(sys)) {
(Ok(x), Ok(y)) => f(x, y),
(Err(e), _) | (_, Err(e)) => Value::Error(e),
};
use std::cmp::Ordering::*;
match op {
BinOp::Add => num(|x, y| Value::number(x + y)),
BinOp::Sub => num(|x, y| Value::number(x - y)),
BinOp::Mul => num(|x, y| Value::number(x * y)),
BinOp::Div => num(|x, y| if y == 0.0 { Value::Error(CellError::Div0) } else { Value::number(x / y) }),
BinOp::Pow => num(|x, y| {
if x == 0.0 && y == 0.0 {
Value::Error(CellError::Num)
} else if x == 0.0 && y < 0.0 {
Value::Error(CellError::Div0)
} else {
Value::number(x.powf(y))
}
}),
BinOp::Concat => match (a.to_text(), b.to_text()) {
(Ok(x), Ok(y)) => {
if x.len() + y.len() > 32767 * 4 {
Value::Error(CellError::Value)
} else {
Value::text(x + &y)
}
}
(Err(e), _) | (_, Err(e)) => Value::Error(e),
},
BinOp::Eq => Value::Bool(compare(a, b) == Equal),
BinOp::Ne => Value::Bool(compare(a, b) != Equal),
BinOp::Lt => Value::Bool(compare(a, b) == Less),
BinOp::Gt => Value::Bool(compare(a, b) == Greater),
BinOp::Le => Value::Bool(compare(a, b) != Greater),
BinOp::Ge => Value::Bool(compare(a, b) != Less),
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);
}
}