diff --git a/crates/functions/src/database.rs b/crates/functions/src/database.rs index 37289d9fb..5d77eb350 100644 --- a/crates/functions/src/database.rs +++ b/crates/functions/src/database.rs @@ -70,27 +70,43 @@ fn cell_criterion(v: &Value) -> Option { /// when the label is unknown, criterion). type CriteriaRow = (usize, Vec<(usize, Option, Criterion)>); +/// Most condition tests one selection may make (records times conditions): every record can be +/// tested against every criteria row when none matches, so a modest database and criteria table +/// multiply into billions of tests. Past it the function is `#NUM!`, as lifted lookups are; 5e8 +/// is a few seconds of matching. +const SELECTION_WORK_BUDGET: usize = 500_000_000; + /// Rows (1-based within the database, excluding the header) of matching records. -fn matching_records(db: &Array, crit: &Array, ctx: &mut dyn Ctx) -> Vec { +fn matching_records(db: &Array, crit: &Array, ctx: &mut dyn Ctx) -> R> { + // The database column under each criteria label, found once per label rather than per cell. + let columns: Vec> = (0..crit.cols) + .map(|c| match crit.get(0, c) { + Some(Value::Text(t)) => find_column(db, t), + Some(Value::Number(n)) => find_column(db, &gridcraft_core::number_to_text(*n)), + _ => None, + }) + .collect(); // Per criteria row: its index and a list of (criteria column, database column or None when // the label is unknown, criterion). let mut rows: Vec = Vec::new(); + let mut tests = 0usize; for r in 1..crit.rows { let mut conds = Vec::new(); for c in 0..crit.cols { let Some(cell) = crit.get(r, c) else { continue }; let Some(cr) = cell_criterion(cell) else { continue }; - let col = match crit.get(0, c) { - Some(Value::Text(t)) => find_column(db, t), - Some(Value::Number(n)) => find_column(db, &gridcraft_core::number_to_text(*n)), - _ => None, - }; - conds.push((c, col, cr)); + conds.push((c, columns.get(c).copied().flatten(), cr)); } + tests = tests.saturating_add(conds.len().max(1)); rows.push((r, conds)); } - let all_match = rows.is_empty(); - (1..db.rows) + // An empty criteria row matches every record without testing a condition, so nothing is charged. + let all_match = rows.is_empty() || rows.iter().any(|(_, conds)| conds.is_empty()); + // Computed criteria (a formula evaluated per record) count like any other test. + if !all_match && db.rows.saturating_sub(1).saturating_mul(tests) > SELECTION_WORK_BUDGET { + return Err(CellError::Num); + } + Ok((1..db.rows) .filter(|&rec| { all_match || rows.iter().any(|(r, conds)| { @@ -102,7 +118,7 @@ fn matching_records(db: &Array, crit: &Array, ctx: &mut dyn Ctx) -> Vec { }) }) }) - .collect() + .collect()) } struct Selection<'a> { @@ -123,7 +139,7 @@ fn select<'a>(a: &'a [Arg], ctx: &mut dyn Ctx) -> R> { let db = as_array(dbv); let crit = as_array(critv); let field = field_index(&db, a)?; - let records = matching_records(&db, &crit, ctx); + let records = matching_records(&db, &crit, ctx)?; Ok(Selection { db, field, records }) } @@ -352,6 +368,27 @@ mod tests { is_err(d("DSUM", n(9.0)), CellError::Value); } + /// Security: records times criteria conditions past the selection budget is `#NUM!` before + /// any record is tested; a nonmatching criteria table used to test every record against + /// every criteria row. + #[test] + fn selection_work_is_bounded() { + use gridcraft_core::Array; + let column = |label: &str, len: usize, v: Value| { + let mut data = vec![tv(label)]; + data.extend(std::iter::repeat_n(v, len)); + Value::from(Array::column(data)) + }; + let db = column("Tree", 100_000, tv("Apple")); + let wide = column("Tree", 5_001, tv("=Plum")); + is_err(ev("DCOUNTA", vec![rf(db.clone()), t("Tree"), rf(wide)]), CellError::Num); + let narrow = column("Tree", 2, tv("=Apple")); + close(ev("DCOUNTA", vec![rf(db.clone()), t("Tree"), rf(narrow)]), 100_000.0); + // Empty criteria rows test no condition: the first one matches every record, so they cost nothing. + let empty_rows = column("Tree", 5_001, Value::Empty); + close(ev("DCOUNTA", vec![rf(db), t("Tree"), rf(empty_rows)]), 100_000.0); + } + #[test] fn simple_criteria() { let c = |cells: Vec| arr(vec![vec![tv("Tree"), tv("Height")], cells]); diff --git a/crates/functions/src/date.rs b/crates/functions/src/date.rs index 21d2a49c1..2060267c2 100644 --- a/crates/functions/src/date.rs +++ b/crates/functions/src/date.rs @@ -416,13 +416,17 @@ fn workday_core(a: &[Arg], c: &mut dyn Ctx, mask: [bool; 7], hol: Option<&Arg>) if days == 0 { return num_val(start as f64); } + // The holidays on working days, still sorted (`holidays` sorts them), so each step counts + // the ones it passed with two binary searches rather than a scan of every holiday: a run of + // consecutive holidays takes one step per holiday. + let hs: Vec = hs.into_iter().filter(|&h| !mask[weekday0(sys, h)]).collect(); let dir = days.signum(); let mut cur = start; let mut rem = days.abs(); while rem > 0 { let next = advance(sys, cur, rem, dir, &mask, per_week); let (lo, hi) = if dir > 0 { (cur + 1, next) } else { (next, cur - 1) }; - rem = hs.iter().filter(|&&h| h >= lo && h <= hi && !mask[weekday0(sys, h)]).count() as i64; + rem = hs.partition_point(|&h| h <= hi).saturating_sub(hs.partition_point(|&h| h < lo)) as i64; cur = next; if !(0..=MAX_SERIAL as i64).contains(&cur) { return Err(CellError::Num); @@ -755,6 +759,23 @@ mod tests { is_err(ev("TIMEVALUE", vec![t("x")]), CellError::Value); } + /// Security: WORKDAY counts the holidays each step passes by binary search, so a long run of + /// consecutive holidays is linear work, not one scan of every holiday per holiday. + #[test] + fn workday_over_many_consecutive_holidays() { + let start = d(2000.0, 1.0, 3.0); + let hol: Vec = (1..=200_000).map(|i| start + f64::from(i)).collect(); + let out = ev("WORKDAY.INTL", vec![n(start), n(1.0), n(11.0), av(col(&hol))]); + // Sundays are the only weekend (11): 200,000 days of holidays are skipped, landing on the + // first working day after them. + let after = start + 200_001.0; + let sunday = ev("WEEKDAY", vec![n(after), n(2.0)]) == nv(7.0); + close(out, if sunday { after + 1.0 } else { after }); + // Holidays on a weekend don't push the result out. + let fri = d(2026.0, 10.0, 9.0); + close(ev("WORKDAY", vec![n(fri), n(1.0), av(col(&[fri + 1.0, fri + 3.0]))]), fri + 4.0); + } + #[test] fn day_counts() { close(ev("DAYS", vec![n(d(2021.0, 3.0, 15.0)), n(d(2021.0, 2.0, 1.0))]), 42.0); diff --git a/crates/functions/src/dynamic.rs b/crates/functions/src/dynamic.rs index 816af8ea2..15e9ff7c9 100644 --- a/crates/functions/src/dynamic.rs +++ b/crates/functions/src/dynamic.rs @@ -171,13 +171,17 @@ fn sort(args: &[Arg], _c: &mut dyn Ctx) -> R { let a = if by_col { a.transpose() } else { a }; let idx = if has(args, 1) { int_list(&arg(args, 1)?.value)? } else { vec![1] }; let ord = if has(args, 2) { int_list(&arg(args, 2)?.value)? } else { vec![1] }; - if idx.is_empty() || idx.iter().any(|&i| i < 1 || i as usize > a.cols) || ord.iter().any(|&o| o != 1 && o != -1) { + if idx.is_empty() || idx.iter().any(|&i| i < 1 || i > a.cols as i64) || ord.iter().any(|&o| o != 1 && o != -1) { return Err(CellError::Value); } if ord.len() > 1 && ord.len() != idx.len() { return Err(CellError::Value); } let orders: Vec = (0..idx.len()).map(|i| *ord.get(i).or(ord.first()).unwrap_or(&1)).collect(); + // A column repeated in sort_index can't break a tie its first use left, so only first uses + // stay: the keys copied per row are then at most the row itself, however long sort_index is. + let mut seen = std::collections::HashSet::new(); + let (idx, orders): (Vec, Vec) = idx.into_iter().zip(orders).filter(|(i, _)| seen.insert(*i)).unzip(); let mut lines: Vec<(Vec, Vec)> = rows_of(&a).into_iter().map(|r| (idx.iter().map(|&i| r.get(i as usize - 1).cloned().unwrap_or(Value::Empty)).collect(), r)).collect(); lines.sort_by(|x, y| cmp_keys(&x.0, &y.0, &orders)); @@ -928,9 +932,16 @@ fn pivotby(args: &[Arg], ctx: &mut dyn Ctx) -> R { let ckeys: Vec> = p.rows.iter().map(|&r| key_row(&cf, r)).collect(); let rk = distinct_keys(&rkeys, if row_sort.unsigned_abs() as usize <= rf.cols { row_sort } else { 0 }); let ck = distinct_keys(&ckeys, if col_sort.unsigned_abs() as usize <= cf.cols { col_sort } else { 0 }); + // Source rows of every row key, column key and pair, built in one pass, so a total reads + // its rows instead of rescanning the whole source once per total. let mut cell: HashMap<(String, String), Vec> = HashMap::new(); + let mut by_row: HashMap> = HashMap::new(); + let mut by_col: HashMap> = HashMap::new(); for ((r, c), &i) in rkeys.iter().zip(ckeys.iter()).zip(p.rows.iter()) { - cell.entry((row_key(r), row_key(c))).or_default().push(i); + let (r, c) = (row_key(r), row_key(c)); + by_row.entry(r.clone()).or_default().push(i); + by_col.entry(c.clone()).or_default().push(i); + cell.entry((r, c)).or_default().push(i); } let mut agg = |rows: &[usize], c: usize| -> Value { if rows.is_empty() { @@ -940,14 +951,12 @@ fn pivotby(args: &[Arg], ctx: &mut dyn Ctx) -> R { aggregate_with(f, &vs, ctx) }; let rows_for = |r: Option<&Vec>, c: Option<&Vec>| -> Vec { - let rkey = r.map(|k| row_key(k)); - let ckey = c.map(|k| row_key(k)); - p.rows - .iter() - .zip(rkeys.iter().zip(ckeys.iter())) - .filter(|(_, (a, b))| rkey.as_ref().is_none_or(|k| *k == row_key(a)) && ckey.as_ref().is_none_or(|k| *k == row_key(b))) - .map(|(&i, _)| i) - .collect() + match (r, c) { + (Some(r), Some(c)) => cell.get(&(row_key(r), row_key(c))).cloned().unwrap_or_default(), + (Some(r), None) => by_row.get(&row_key(r)).cloned().unwrap_or_default(), + (None, Some(c)) => by_col.get(&row_key(c)).cloned().unwrap_or_default(), + (None, None) => p.rows.clone(), + } }; let mut cols: Vec>> = ck.iter().map(Some).collect(); if col_total > 0 { @@ -997,10 +1006,7 @@ fn pivotby(args: &[Arg], ctx: &mut dyn Ctx) -> R { } }; for c in &cols { - let rows = match (r, c) { - (Some(rk), Some(ck)) => cell.get(&(row_key(rk), row_key(ck))).cloned().unwrap_or_default(), - _ => rows_for(r, *c), - }; + let rows = rows_for(r, *c); for vc in 0..vals.cols { line.push(agg(&rows, vc)); } @@ -1204,6 +1210,21 @@ mod tests { assert_eq!(rows_of(&r), vec![vec![nv(0.0), nv(5.0)], vec![nv(1.0), nv(2.0)], vec![nv(1.0), nv(1.0)]]); } + /// Security: a column repeated in sort_index keys the sort once. The keys copied per row stay + /// within the row, however long sort_index is, and a repeat (even with the other order) can't + /// change the result, since its first use already ordered the rows. + #[test] + fn sort_drops_repeated_sort_indices() { + let m = || av(arr(vec![vec![nv(1.0), nv(2.0)], vec![nv(1.0), nv(1.0)], vec![nv(0.0), nv(5.0)]])); + let want = vec![vec![nv(0.0), nv(5.0)], vec![nv(1.0), nv(2.0)], vec![nv(1.0), nv(1.0)]]; + let r = ev("SORT", vec![m(), av(row(&[1.0, 1.0, 2.0, 1.0])), av(row(&[1.0, -1.0, -1.0, 1.0]))]); + assert_eq!(rows_of(&r), want); + let idx = vec![1.0; 1_000_000]; + let tall = av(gridcraft_core::Array::filled(2_000, 1, nv(1.0)).into()); + let r = ev("SORT", vec![tall, av(row(&idx))]); + assert_eq!(rows_of(&r).len(), 2_000); + } + #[test] fn sortby() { let r = ev("SORTBY", vec![av(col(&[1.0, 2.0, 3.0])), av(col(&[30.0, 10.0, 20.0]))]); @@ -1447,6 +1468,16 @@ mod tests { ); } + /// Security: PIVOTBY totals read their source rows from indexes built in the grouping pass, + /// rather than rescanning the source once per total, so many distinct keys stay linear. + #[test] + fn pivotby_totals_with_many_groups() { + let keys: Vec = (0..20_000).map(f64::from).collect(); + let r = rows_of(&ev("PIVOTBY", vec![av(col(&keys)), av(col(&vec![1.0; 20_000])), av(col(&vec![2.0; 20_000])), t("SUM")])); + assert_eq!(r.get(1), Some(&vec![nv(0.0), nv(2.0), nv(2.0)])); + assert_eq!(r.last(), Some(&vec![tv("Total"), nv(40_000.0), nv(40_000.0)])); + } + #[test] fn pivotby_size_check_counts_column_header_rows() { // One row, n column fields and n value columns: n header rows + value-name row + body row + total row,