From 39b3d55f8a0185827067f068da9b396baf1fbbf7 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Tue, 13 Oct 2026 01:16:05 +0200 Subject: [PATCH] CSV export: refuse a file over 512 MiB instead of expanding the whole used range (#528) write_csv visited every position of the used range and appended to one growing String with no limit, so two cells far apart (A1 and XFD1048576) asked for about 16 GiB of commas. The output is now capped at 512 MiB: a lower bound (delimiters and line ends of every row) is checked before anything is allocated, and the text written so far is checked after each row. Over the cap, write_csv returns IoError::TooLarge naming the rows, columns and limit, and Save As / Export CSV and TSV report it without writing a file. Accepted exports are unchanged. write_csv now returns a Result. --- crates/engine/src/io.rs | 7 +-- crates/engine/tests/csv_export_budget.rs | 45 +++++++++++++++++++ crates/xlsx/src/csv.rs | 33 ++++++++++++-- crates/xlsx/src/lib.rs | 2 +- crates/xlsx/src/tests/csv_tests.rs | 56 ++++++++++++++++++++++-- 5 files changed, 132 insertions(+), 11 deletions(-) create mode 100644 crates/engine/tests/csv_export_budget.rs diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index bc7011d..5453be8 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -164,8 +164,8 @@ pub fn save_bytes(wb: &Workbook, path: &str) -> Result> { FileKind::Xlsb => Err(EngineError::Other("XLSB is supported for data import only. Save as .xlsx or another supported export format.".into())), FileKind::Ods => Err(EngineError::Other("ODS is supported for data import only. Save as .xlsx or another supported export format.".into())), // A workbook read from CSV keeps the separator it was read with (a `;` file stays `;`). - FileKind::Csv => Ok(wb.sheet(sheet).map(|sh| csv(wb, sh, wb.csv_delimiter.unwrap_or(b','))).unwrap_or_default()), - FileKind::Tsv => Ok(wb.sheet(sheet).map(|sh| csv(wb, sh, b'\t')).unwrap_or_default()), + FileKind::Csv => wb.sheet(sheet).map(|sh| csv(wb, sh, wb.csv_delimiter.unwrap_or(b','))).transpose().map(Option::unwrap_or_default), + FileKind::Tsv => wb.sheet(sheet).map(|sh| csv(wb, sh, b'\t')).transpose().map(Option::unwrap_or_default), FileKind::Json => serde_json::to_vec_pretty(wb).map_err(|e| EngineError::Other(e.to_string())), FileKind::Html => Ok(to_html(wb, sheet).into_bytes()), } @@ -174,7 +174,7 @@ pub fn save_bytes(wb: &Workbook, path: &str) -> Result> { /// Save As CSV/Text writes "the text and values as they are displayed in cells" with "all /// characters in each cell" (Microsoft Support, *Excel formatting and features that are not /// transferred to other file formats*): number formats applied, never column-width `#####`. -fn csv(wb: &Workbook, sh: &Sheet, delimiter: u8) -> Vec { +fn csv(wb: &Workbook, sh: &Sheet, delimiter: u8) -> Result> { gridcraft_xlsx::write_csv(sh, delimiter, |c| { // General's grid-width formatting rounds large identifiers to scientific notation. // Delimited files have no column width, so preserve the whole numeric value instead. @@ -187,6 +187,7 @@ fn csv(wb: &Workbook, sh: &Sheet, delimiter: u8) -> Vec { crate::display::value_text(wb, sh, c) } }) + .map_err(|e| EngineError::Other(e.to_string())) } fn esc(s: &str) -> String { diff --git a/crates/engine/tests/csv_export_budget.rs b/crates/engine/tests/csv_export_budget.rs new file mode 100644 index 0000000..22068d1 --- /dev/null +++ b/crates/engine/tests/csv_export_budget.rs @@ -0,0 +1,45 @@ +//! CSV and TSV export is capped: two cells far apart in a sheet would otherwise make a file as +//! big as the whole rectangle between them. A refused export is an error and writes nothing. + +use gridcraft_engine::Session; +use serde_json::json; + +fn two_far_cells() -> Session { + let mut s = Session::new(); + s.new_workbook(); + s.execute("cell.set", json!({"cell": "A1", "input": "a"})).unwrap(); + s.execute("cell.set", json!({"cell": "XFD1048576", "input": "b"})).unwrap(); + s +} + +#[test] +fn a_sparse_sheet_spanning_the_grid_is_refused_not_expanded() { + let mut s = two_far_cells(); + for format in ["csv", "tsv"] { + let err = s.execute("file.saveBytes", json!({"format": format})).unwrap_err().to_string(); + assert!(err.contains("1048576 rows by 16384 columns") && err.contains("MiB"), "{format}: {err}"); + } + // Save As and Export write no file, and the document stays as it was. + let dir = std::env::temp_dir().join(format!("gridcraft-csv-budget-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("big.csv"); + let err = s.execute("file.exportCsv", json!({"path": path.to_str().unwrap()})).unwrap_err().to_string(); + assert!(err.contains("over the limit"), "{err}"); + assert!(!path.exists(), "nothing is written for a refused export"); + std::fs::remove_dir_all(&dir).unwrap(); + // Other formats are unaffected, and so is a small export from the same session. + assert!(s.execute("file.saveBytes", json!({"format": "xlsx"})).is_ok()); + s.execute("cell.set", json!({"cell": "XFD1048576", "input": ""})).unwrap(); + assert_eq!(s.execute("file.saveBytes", json!({"format": "csv"})).unwrap()["bytes"], 3); +} + +#[test] +fn exports_below_the_cap_keep_their_positions() { + for (corner, bytes) in [("C3", 14), ("Z10", 272), ("XFD8", 131_082)] { + let mut s = Session::new(); + s.new_workbook(); + s.execute("cell.set", json!({"cell": "A1", "input": "a"})).unwrap(); + s.execute("cell.set", json!({"cell": corner, "input": "b"})).unwrap(); + assert_eq!(s.execute("file.saveBytes", json!({"format": "csv"})).unwrap()["bytes"], bytes, "{corner}"); + } +} diff --git a/crates/xlsx/src/csv.rs b/crates/xlsx/src/csv.rs index 2becc0e..47486bf 100644 --- a/crates/xlsx/src/csv.rs +++ b/crates/xlsx/src/csv.rs @@ -8,6 +8,9 @@ use gridcraft_model::{Cell, NumFmt, Sheet, StyleId, Workbook}; use crate::{CsvOptions, IoError}; +/// The largest CSV or tab-separated file written (bytes). +pub const MAX_CSV_BYTES: usize = 512 << 20; + /// Windows-1252 code points for bytes 0x80–0x9F (others map to Latin-1). const CP1252: [u32; 32] = [ 0x20AC, 0x81, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0x02C6, 0x2030, 0x0160, 0x2039, 0x0152, 0x8D, 0x017D, 0x8F, 0x90, 0x2018, 0x2019, @@ -187,10 +190,31 @@ pub fn read_csv(bytes: &[u8], opts: &CsvOptions) -> Result { /// Writes the sheet's used range as CSV (CRLF line ends, UTF-8 without BOM). `cell_text` gives /// each cell's field text: the caller decides how values look (Save As CSV writes them as /// displayed, number formats applied); quoting is done here. -pub fn write_csv(sheet: &Sheet, delimiter: u8, mut cell_text: impl FnMut(CellRef) -> String) -> Vec { +/// +/// The output is capped at [`MAX_CSV_BYTES`]: two cells far apart make the used range, and so the +/// file, as big as the whole rectangle between them (`A1` and `XFD1048576` are 16 GiB of commas). +/// An export that would pass the cap is refused before anything is allocated, or as soon as the +/// text written so far passes it, with [`IoError::TooLarge`]; accepted exports keep every field in +/// its place. +pub fn write_csv(sheet: &Sheet, delimiter: u8, cell_text: impl FnMut(CellRef) -> String) -> Result, IoError> { + write_csv_limited(sheet, delimiter, MAX_CSV_BYTES, cell_text) +} + +/// [`write_csv`] with the output cap `limit` (bytes) given. +pub fn write_csv_limited(sheet: &Sheet, delimiter: u8, limit: usize, mut cell_text: impl FnMut(CellRef) -> String) -> Result, IoError> { let delim = if delimiter == 0 { ',' } else { delimiter as char }; let mut out = String::new(); - let Some(r) = sheet.used_range() else { return Vec::new() }; + let Some(r) = sheet.used_range() else { return Ok(Vec::new()) }; + let too_large = + |what: String| IoError::TooLarge(format!("the CSV would be {what}, over the limit of {} MiB; export a smaller range", limit >> 20)); + // Every row has a delimiter between its fields and a CRLF, whatever the cells hold: a lower + // bound on the output that needs no cell text. + let (rows, cols) = (u64::from(r.end.row - r.start.row) + 1, u64::from(r.end.col - r.start.col) + 1); + let per_row = (cols - 1).saturating_mul(delim.len_utf8() as u64).saturating_add(2); + let least = rows.saturating_mul(per_row); + if least > limit as u64 { + return Err(too_large(format!("at least {} MiB ({rows} rows by {cols} columns)", least >> 20))); + } for row in r.start.row..=r.end.row { for col in r.start.col..=r.end.col { if col > r.start.col { @@ -206,6 +230,9 @@ pub fn write_csv(sheet: &Sheet, delimiter: u8, mut cell_text: impl FnMut(CellRef } } out.push_str("\r\n"); + if out.len() > limit { + return Err(too_large(format!("more than {} MiB (stopped at row {})", limit >> 20, row + 1))); + } } - out.into_bytes() + Ok(out.into_bytes()) } diff --git a/crates/xlsx/src/lib.rs b/crates/xlsx/src/lib.rs index b5e4bb6..6fd80d9 100644 --- a/crates/xlsx/src/lib.rs +++ b/crates/xlsx/src/lib.rs @@ -45,7 +45,7 @@ use gridcraft_core::DateSystem; use gridcraft_model::Workbook; pub use crypto::{DecryptError, check_encryption, decrypt}; -pub use csv::{read_csv, write_csv}; +pub use csv::{MAX_CSV_BYTES, read_csv, write_csv, write_csv_limited}; pub use ods::read_ods; pub use xls::read_xls; pub use xlsb::read_xlsb; diff --git a/crates/xlsx/src/tests/csv_tests.rs b/crates/xlsx/src/tests/csv_tests.rs index c595155..cc52072 100644 --- a/crates/xlsx/src/tests/csv_tests.rs +++ b/crates/xlsx/src/tests/csv_tests.rs @@ -2,7 +2,7 @@ use gridcraft_core::{CellRef, DateSystem, Value}; use gridcraft_model::{Sheet, Workbook}; use crate::csv::{decode_text, sniff_delimiter}; -use crate::{CsvOptions, Format, read_csv, sniff, write_csv}; +use crate::{CsvOptions, Format, IoError, read_csv, sniff, write_csv, write_csv_limited}; fn v(wb: &Workbook, r: u32, c: u32) -> Value { wb.sheet(0).unwrap().value(CellRef::new(r, c)) @@ -109,11 +109,11 @@ fn writing() { Value::Error(e) => e.as_str().into(), Value::Array(_) => String::new(), }; - let out = String::from_utf8(write_csv(sh, b',', raw)).unwrap(); + let out = String::from_utf8(write_csv(sh, b',', raw).unwrap()).unwrap(); assert_eq!(out, "\"a,b\",\"q\"\"t\",\r\n0.1,FALSE,\r\n#DIV/0!,,\"x\ny\"\r\n"); - let semi = String::from_utf8(write_csv(sh, b';', raw)).unwrap(); + let semi = String::from_utf8(write_csv(sh, b';', raw).unwrap()).unwrap(); assert!(semi.starts_with("a,b;")); - assert!(write_csv(&Sheet::new("E"), b',', raw).is_empty()); + assert!(write_csv(&Sheet::new("E"), b',', raw).unwrap().is_empty()); // Round trip. let back = read_csv(out.as_bytes(), &CsvOptions::default()).unwrap(); assert_eq!(v(&back, 0, 0), Value::text("a,b")); @@ -142,3 +142,51 @@ fn hostile_csv() { let wb = read_csv(format!("{wide}x").as_bytes(), &CsvOptions::default()).unwrap(); assert!(wb.sheet(0).unwrap().cells.is_empty(), "fields past column XFD are dropped"); } + +/// A sheet with `a` in A1 and `b` in `corner`. +fn two_cells(corner: (u32, u32)) -> Sheet { + let mut s = Sheet::new("S"); + s.set_value(CellRef::new(0, 0), Value::text("a")); + s.set_value(CellRef::new(corner.0, corner.1), Value::text("b")); + s +} + +fn text_of(sh: &Sheet) -> impl FnMut(CellRef) -> String + '_ { + |c| match sh.value(c) { + Value::Text(t) => t.to_string(), + _ => String::new(), + } +} + +#[test] +fn two_far_apart_cells_are_refused_before_anything_is_allocated() { + // A1 and XFD1048576 span the whole grid: 16 GiB of commas. Refused at once, with the numbers. + let s = two_cells((gridcraft_core::MAX_ROWS - 1, gridcraft_core::MAX_COLS - 1)); + let err = write_csv(&s, b',', text_of(&s)).unwrap_err(); + assert!(matches!(err, IoError::TooLarge(_)), "{err}"); + let msg = err.to_string(); + assert!(msg.contains("1048576 rows by 16384 columns") && msg.contains("512 MiB"), "{msg}"); + // A single row or column across the grid is refused too when it is over the cap. + let wide = two_cells((gridcraft_core::MAX_ROWS - 1, 0)); + assert!(write_csv_limited(&wide, b',', 1 << 20, text_of(&wide)).is_err()); +} + +#[test] +fn the_cap_counts_the_bytes_the_cells_hold_and_accepted_exports_keep_every_position() { + // 3 rows x 3 columns, 14 bytes: accepted at the cap, refused one byte under. + let s = two_cells((2, 2)); + let ok = write_csv_limited(&s, b',', 14, text_of(&s)).unwrap(); + assert_eq!(String::from_utf8(ok).unwrap(), "a,,\r\n,,\r\n,,b\r\n"); + assert!(write_csv_limited(&s, b',', 13, text_of(&s)).is_err()); + // Rows that pass the lower bound but hold long text stop as soon as the cap is passed. + let mut long = Sheet::new("L"); + for r in 0..50u32 { + long.set_value(CellRef::new(r, 0), Value::text("x".repeat(100))); + } + let err = write_csv_limited(&long, b',', 1000, text_of(&long)).unwrap_err(); + assert!(err.to_string().contains("stopped at row"), "{err}"); + assert!(write_csv_limited(&long, b',', 50 * 102, text_of(&long)).is_ok()); + // A tab is one byte, like a comma. + let tabs = write_csv_limited(&s, b'\t', 14, text_of(&s)).unwrap(); + assert_eq!(tabs.len(), 14); +}