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 1/3] 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); +} From d1260e1878c41894ad6171404ee51b8f0e9396ed Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Tue, 13 Oct 2026 01:17:16 +0200 Subject: [PATCH 2/3] Docs: update the progress docs for this change --- ROADMAP.md | 2 ++ docs/file-format-parity.md | 7 ++++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index e8e1c06..94ae973 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-12 | CSV and tab-separated export is capped at 512 MiB: a sheet whose used range would expand to more (two cells far apart, such as A1 and XFD1048576, are 16 GiB of commas) is refused with the rows, columns and limit, before anything is allocated or written (#528) | | 2026-10-12 | File › Print selects a system printer, copies, duplex and grayscale, submits PDF jobs through craft-print on a worker and shows spooler errors; browser printing and PDF export remain available | | 2026-10-11 | Command-sequence fuzzing: random command sequences checked against undo/redo, XLSX round-trip and full recalculation; fixes for the table, legacy-array, spill, AutoSum, Watch Window and sheet-copy bugs it found | | 2026-10-11 | Password-protected workbooks open: GridCraft asks for the password and decrypts Agile and Standard encryption (#11) | @@ -178,6 +179,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | Change | Summary | |---|---|---| +| 2026-10-12 | trivial | Progress log: CSV export size cap (#528) | | 2026-10-12 | minor | Desktop File > Print now discovers printers and submits PDF jobs through pinned craft-print with printer choice, copies, duplex and grayscale; browser printing and PDF export remain available. Remaining print parity includes page ranges and driver-specific tray/options UI; physical printer fidelity needs hardware validation. | | 2026-10-11 | minor | Password-encrypted workbooks open (#11); progress log row | | 2026-10-11 | minor | Forecast Sheet (#322); progress log row | diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 8510659..5cf441c 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (CSV save keeps the separator it was opened with) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** trivial (CSV and TSV export is capped at 512 MiB and refuses a larger used range with an error (#528); previously minor: CSV save keeps the separator it was opened with) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Every format Excel opens or saves, with GridCraft's read and write support, fidelity and tests. Part of [target-app-parity.md](target-app-parity.md). **File formats: ~50% ready, 80–120 h** @@ -34,9 +34,9 @@ support comes from `crates/engine/src/io.rs` (`FileKind`), `crates/xlsx/src` and | Format | Excel | GridCraft read | GridCraft write | Notes | |---|---|---|---|---| -| `.csv` UTF-8 / Windows / Mac / MS-DOS | R/W | yes (UTF-8 and UTF-16 BOMs, UTF-8, Windows-1252 fallback; delimiter detection) | yes (UTF-8 without BOM, CRLF; a file opened as CSV is saved with the separator it was read with, so a `;` file stays `;`; otherwise `,`) | Excel writes the **displayed** values; we write raw values. No encoding choice on save. Re-import of a single-column export splits text (#127) | +| `.csv` UTF-8 / Windows / Mac / MS-DOS | R/W | yes (UTF-8 and UTF-16 BOMs, UTF-8, Windows-1252 fallback; delimiter detection) | yes (UTF-8 without BOM, CRLF; a file opened as CSV is saved with the separator it was read with, so a `;` file stays `;`; otherwise `,`) | Excel writes the **displayed** values; we write raw values. No encoding choice on save. Re-import of a single-column export splits text (#127) A used range that would write more than 512 MiB (two far-apart cells span the whole rectangle) is refused with an error instead of being expanded (#528). | | `.txt` tab-delimited, Unicode text | R/W | yes (`.txt`, `.tsv`, `.tab`) | yes | Text Import Wizard options partly via Text to Columns | -| `.csv` UTF-8 / Windows / Mac / MS-DOS | R/W | yes (UTF-8 and UTF-16 BOMs, UTF-8, Windows-1252 fallback; delimiter detection) | yes (UTF-8 without BOM, CRLF) | Writes the **displayed** values like Excel (number formats applied, formula results, never column-width `#####`). No encoding choice on save. Re-import of a single-column export splits text (#127) | +| `.csv` UTF-8 / Windows / Mac / MS-DOS | R/W | yes (UTF-8 and UTF-16 BOMs, UTF-8, Windows-1252 fallback; delimiter detection) | yes (UTF-8 without BOM, CRLF) | Writes the **displayed** values like Excel (number formats applied, formula results, never column-width `#####`). No encoding choice on save. Re-import of a single-column export splits text (#127) A used range that would write more than 512 MiB (two far-apart cells span the whole rectangle) is refused with an error instead of being expanded (#528). | | `.txt` tab-delimited, Unicode text | R/W | yes (`.txt`, `.tsv`, `.tab`) | yes (displayed values) | Text Import Wizard options partly via Text to Columns | | `.prn` formatted text (space-delimited) | R/W | no | no | | | `.dif`, `.slk` (SYLK) | R/W | no | no | Small, legacy | @@ -78,6 +78,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | trivial | CSV and TSV export is capped at 512 MiB and refuses a larger used range with an error (#528) | | 2026-10-12 | minor | CSV: saving a file opened as CSV keeps its detected separator (`;` files were rewritten with `,`) | | 2026-10-11 | minor | XLSX: cell/shared/array formulas read through the file→model conversion (`_xlpm.` LET/LAMBDA parameter prefixes stripped); table column names keep line breaks; table names may start with `\` | | 2026-10-11 | minor | CSV/TSV export writes displayed values (number formats applied, no `#####`); `.xltx`, `.xltm` and `.xlsm` saved with their own workbook content types | From daeda9db472e89a845ece49b320135c01fa80c1f Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Tue, 13 Oct 2026 01:17:16 +0200 Subject: [PATCH 3/3] Credit Silvertree2010 in the About window --- contributors/people.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index 5d343e9..d522b84 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -61,3 +61,7 @@ sync_github_name = true real_name = "James Symes" sync_github_name = true + +[people.Silvertree2010] +display_name = "Netter" +sync_github_name = false