From 01032e9010cc68fa684cacc9140a354a0683fa4f Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:02:01 +0200 Subject: [PATCH] Cap CSV import fields per record at the worksheet column limit (#1037) --- crates/xlsx/src/csv.rs | 19 ++++++++++++++----- crates/xlsx/src/tests/csv_tests.rs | 25 +++++++++++++++++++++++-- 2 files changed, 37 insertions(+), 7 deletions(-) diff --git a/crates/xlsx/src/csv.rs b/crates/xlsx/src/csv.rs index 47486bfa2..946fafe18 100644 --- a/crates/xlsx/src/csv.rs +++ b/crates/xlsx/src/csv.rs @@ -87,8 +87,9 @@ pub fn sniff_delimiter(text: &str, quote: char) -> u8 { best.0 } -/// Splits CSV text into records of fields. -fn parse_records(text: &str, delim: char, quote: char, mut on_record: impl FnMut(Vec) -> bool) { +/// Splits CSV text into records of fields. A record keeps at most [`MAX_COLS`] fields; the rest are +/// dropped as they are read, so a row of endless delimiters cannot allocate a string per field. +pub(crate) fn parse_records(text: &str, delim: char, quote: char, mut on_record: impl FnMut(Vec) -> bool) { let mut field = String::new(); let mut record: Vec = vec![]; let mut in_q = false; @@ -112,13 +113,19 @@ fn parse_records(text: &str, delim: char, quote: char, mut on_record: impl FnMut in_q = true; field_started_quoted = true; } else if c == delim { - record.push(std::mem::take(&mut field)); + let f = std::mem::take(&mut field); + if record.len() < MAX_COLS as usize { + record.push(f); + } field_started_quoted = false; } else if c == '\r' || c == '\n' { if c == '\r' && chars.peek() == Some(&'\n') { chars.next(); } - record.push(std::mem::take(&mut field)); + let f = std::mem::take(&mut field); + if record.len() < MAX_COLS as usize { + record.push(f); + } field_started_quoted = false; if !on_record(std::mem::take(&mut record)) { return; @@ -128,7 +135,9 @@ fn parse_records(text: &str, delim: char, quote: char, mut on_record: impl FnMut } } if !field.is_empty() || !record.is_empty() || field_started_quoted { - record.push(field); + if record.len() < MAX_COLS as usize { + record.push(field); + } on_record(record); } } diff --git a/crates/xlsx/src/tests/csv_tests.rs b/crates/xlsx/src/tests/csv_tests.rs index cc52072e7..c0a774b0d 100644 --- a/crates/xlsx/src/tests/csv_tests.rs +++ b/crates/xlsx/src/tests/csv_tests.rs @@ -1,7 +1,7 @@ -use gridcraft_core::{CellRef, DateSystem, Value}; +use gridcraft_core::{CellRef, DateSystem, MAX_COLS, Value}; use gridcraft_model::{Sheet, Workbook}; -use crate::csv::{decode_text, sniff_delimiter}; +use crate::csv::{decode_text, parse_records, sniff_delimiter}; use crate::{CsvOptions, Format, IoError, read_csv, sniff, write_csv, write_csv_limited}; fn v(wb: &Workbook, r: u32, c: u32) -> Value { @@ -190,3 +190,24 @@ fn the_cap_counts_the_bytes_the_cells_hold_and_accepted_exports_keep_every_posit let tabs = write_csv_limited(&s, b'\t', 14, text_of(&s)).unwrap(); assert_eq!(tabs.len(), 14); } + +#[test] +fn parser_stops_collecting_fields_at_max_cols() { + let extra = 50; + // MAX_COLS + extra fields on the first row (the last one filled), then a short second row. + let mut text = ",".repeat((MAX_COLS + extra) as usize - 1); + text.push_str("end\nx,y\n"); + let mut lens = vec![]; + parse_records(&text, ',', '"', |rec| { + lens.push(rec.len()); + true + }); + assert_eq!(lens, vec![MAX_COLS as usize, 2]); + // An unterminated last row is capped the same way. + let mut lens = vec![]; + parse_records(&",".repeat(MAX_COLS as usize + extra as usize), ',', '"', |rec| { + lens.push(rec.len()); + true + }); + assert_eq!(lens, vec![MAX_COLS as usize]); +}