From 052ee60218ed305d93e72c2fa7fe0d99b512f969 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Mon, 2 Nov 2026 20:34:32 -0500 Subject: [PATCH] Get Data: refuse an import whose dense rows would be too large data.getData and data.fromTextCsv built every row densely before writing it: JSON records were padded to the union of all keys (found with a quadratic Vec::contains scan), and CSV cells were expanded over the whole used rectangle. A few sparse records or one far-away CSV cell therefore allocated billions of slots. The importer now collects JSON keys with a HashSet, refuses more keys than a sheet has columns, and checks the projected rows x columns against MAX_IMPORT_CELLS (10,000,000) before building any row. --- crates/engine/src/cmd/extra/data.rs | 53 ++++++++++++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/extra/data.rs b/crates/engine/src/cmd/extra/data.rs index 9a1ae7417..a3f69bc2e 100644 --- a/crates/engine/src/cmd/extra/data.rs +++ b/crates/engine/src/cmd/extra/data.rs @@ -42,6 +42,15 @@ pub(super) fn table_resize(s: &mut Session, p: &Json) -> Result { }) } +/// Most cells (rows × columns, blanks included) Get Data imports. The rows are built densely +/// before they are written, so a few sparse records or one far-away CSV cell must not expand +/// into a rectangle of billions of slots. +const MAX_IMPORT_CELLS: u64 = 10_000_000; + +fn too_many_cells() -> EngineError { + EngineError::Other(format!("The data is too large to import ({MAX_IMPORT_CELLS} cells at most).")) +} + pub(super) fn from_csv(s: &mut Session, p: &Json) -> Result { let bytes = if let Some(t) = str_param(p, "text") { t.as_bytes().to_vec() @@ -57,15 +66,22 @@ pub(super) fn from_csv(s: &mut Session, p: &Json) -> Result { if text.trim_start().starts_with('[') { let arr: Vec = serde_json::from_str(&text).map_err(|e| bad("data.getData", e.to_string()))?; let mut keys: Vec = Vec::new(); + let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new(); for o in &arr { if let Some(m) = o.as_object() { for k in m.keys() { - if !keys.contains(k) { + if seen.insert(k.as_str()) { keys.push(k.clone()); } } } } + if keys.len() > gridcraft_core::MAX_COLS as usize { + return Err(EngineError::Other("The data has more fields than a sheet has columns.".into())); + } + if (arr.len() as u64 + 1).saturating_mul(keys.len().max(1) as u64) > MAX_IMPORT_CELLS { + return Err(too_many_cells()); + } let mut out = vec![keys.iter().map(|k| json!(k)).collect::>()]; for o in &arr { out.push(keys.iter().map(|k| o.get(k).cloned().unwrap_or(Json::Null)).collect()); @@ -79,6 +95,9 @@ pub(super) fn from_csv(s: &mut Session, p: &Json) -> Result { let wb = gridcraft_xlsx::read_csv(&bytes, &opts).map_err(|e| EngineError::Other(e.to_string()))?; let sh = wb.sheet(0).ok_or(EngineError::NoDocument)?; let Some(u) = sh.used_range() else { return Err(EngineError::Other("The file is empty.".into())) }; + if u.count() > MAX_IMPORT_CELLS { + return Err(too_many_cells()); + } (u.start.row..=u.end.row) .map(|r| { (u.start.col..=u.end.col) @@ -129,3 +148,35 @@ pub(super) fn queries(s: &mut Session, _: &Json) -> Result { .collect(), )) } + +#[cfg(test)] +mod tests { + use serde_json::json; + + use crate::Session; + + fn s() -> Session { + let mut s = Session::new(); + s.new_workbook(); + s + } + + #[test] + fn sparse_imports_are_refused_before_their_rows_are_built() { + let mut s = s(); + let sheets = s.doc().unwrap().wb.sheets.len(); + // 5,000 records with one field each: 5,001 × 5,000 slots once every record gets every field. + let records: Vec<_> = (0..5_000).map(|i| json!({ format!("k{i}"): i })).collect(); + let text = serde_json::to_string(&records).unwrap(); + let err = s.execute("data.getData", json!({"text": text})).expect_err("too many cells"); + assert!(err.to_string().contains("too large"), "{err}"); + // A CSV whose last line reaches 16,001 columns, 1,000 lines down. + let text = format!("1{}{}x", "\n".repeat(999), ",".repeat(16_000)); + let err = s.execute("data.fromTextCsv", json!({"text": text})).expect_err("too many cells"); + assert!(err.to_string().contains("too large"), "{err}"); + assert_eq!(s.doc().unwrap().wb.sheets.len(), sheets); + // Small imports still work. + let out = s.execute("data.getData", json!({"text": r#"[{"a": 1}, {"b": 2}]"#})).unwrap(); + assert_eq!((out["rows"].as_u64(), out["cols"].as_u64()), (Some(3), Some(2)), "{out}"); + } +}