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); +}