From 5951706ba069c0395b0402153ac7310f7eb77182 Mon Sep 17 00:00:00 2001 From: gaoflow Date: Tue, 28 Jul 2026 13:31:31 +0200 Subject: [PATCH] Ignore quotes when detecting the row separator `:auto` row separator detection scanned the sample for the first CR/LF without tracking quote state, so a CR inside a quoted field was mistaken for the row separator. As a result CSV could not parse its own output: `CSV.parse(CSV.generate)` corrupted or crashed on any field containing a carriage return. For example `["a\rb", "x"]` round-tripped to `["a\rb", "x\n"]`, and fields with `\r\n` or a lone `\r` raised CSV::MalformedCSVError. Make detection skip CR/LF that fall inside a quoted field. Unquoted CR is still honored as a separator, so old Mac line endings keep working. --- lib/csv/parser.rb | 21 +++++++++++++++++--- test/csv/parse/test_row_separator.rb | 29 ++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/lib/csv/parser.rb b/lib/csv/parser.rb index 25f10dfc..49968536 100644 --- a/lib/csv/parser.rb +++ b/lib/csv/parser.rb @@ -731,11 +731,11 @@ def resolve_row_separator(separator) end def detect_row_separator(sample, cr, lf) - lf_index = sample.index(lf) + lf_index = find_unquoted_string_index(sample, lf) if lf_index - cr_index = sample[0, lf_index].index(cr) + cr_index = find_unquoted_string_index(sample[0, lf_index], cr) else - cr_index = sample.index(cr) + cr_index = find_unquoted_string_index(sample, cr) end if cr_index and lf_index if cr_index + 1 == lf_index @@ -754,6 +754,21 @@ def detect_row_separator(sample, cr, lf) end end + # Index of the first +target+ that is outside a quoted field, so a CR or + # LF inside quotes is not mistaken for the row separator. + def find_unquoted_string_index(text, target) + return text.index(target) if @quote_character.nil? + in_quote = false + text.each_char.with_index do |char, i| + if char == @quote_character + in_quote = !in_quote + elsif char == target and not in_quote + return i + end + end + nil + end + def prepare_line @lineno = 0 @last_line = nil diff --git a/test/csv/parse/test_row_separator.rb b/test/csv/parse/test_row_separator.rb index 5fd8e751..53b97045 100644 --- a/test/csv/parse/test_row_separator.rb +++ b/test/csv/parse/test_row_separator.rb @@ -13,4 +13,33 @@ def test_multiple_characters CSV.parse("a\r\nb\r\n", row_sep: "\r\n")) end end + + # :auto detection must ignore CR/LF inside a quoted field. + def test_auto_quoted_cr + assert_equal([["a\rb", "x"]], + CSV.parse("\"a\rb\",x\n")) + end + + def test_auto_quoted_cr_lf + assert_equal([["a\r\nb", "x"]], + CSV.parse("\"a\r\nb\",x\n")) + end + + def test_auto_quoted_cr_only_field + assert_equal([["\r"]], + CSV.parse("\"\r\"\n")) + end + + # An unquoted CR is still a valid (old Mac) row separator. + def test_auto_unquoted_cr_still_detected + assert_equal([["a"], ["b"]], + CSV.parse("a\rb\r")) + end + + # CSV must be able to parse its own generated output. + def test_auto_round_trip_cr_fields + rows = [["a\rb", "x"], ["\r", "y"], ["c", "d\r\ne"]] + generated = CSV.generate {|csv| rows.each {|row| csv << row}} + assert_equal(rows, CSV.parse(generated)) + end end