Skip to content

Strip a byte order mark at the start of the input - #2387

Open
dngr2 wants to merge 1 commit into
JoshClose:masterfrom
dngr2:fix/2311-strip-byte-order-mark
Open

Strip a byte order mark at the start of the input#2387
dngr2 wants to merge 1 commit into
JoshClose:masterfrom
dngr2:fix/2311-strip-byte-order-mark

Conversation

@dngr2

@dngr2 dngr2 commented Aug 16, 2026

Copy link
Copy Markdown

Fixes #2311.

The problem

A StreamReader consumes the BOM while detecting the encoding, so this never shows up when reading a file. It shows up when the text arrives already decoded — a StringReader over an API response, a database column, a decompressed buffer.

Left in place, the mark joins the first field:

Input Before After
"\"Id\",\"Name\"" (no BOM) ["Id","Name"] unchanged
BOM + quoted BadDataException ["Id","Name"]
BOM + unquoted ["Id","Name"] ["Id","Name"]
BOM bytes via StreamReader ["Id","Name"] unchanged

The quoted case throws because the field starts with a character before its opening quote. The unquoted case is quieter and arguably worse: the first header becomes Id, so mapping by name silently misses that column.

The change

The mark is dropped from the buffer on the first fill rather than skipped over, so RawRecord, CharCount and the field positions all read as though it was never sent — the same result a StreamReader would have produced.

Only the very start of the input qualifies. A U+FEFF anywhere else is data and is kept, which has its own test.

Both FillBuffer and FillBufferAsync are covered, guarded by a flag so a mid-document refill cannot strip a second time.

Tests

tests/CsvHelper.Tests/Parsing/ByteOrderMarkTests.cs — quoted and unquoted first fields, RawRecord, a mark inside the document, a document with no mark, a BufferSize = 4 document that refills mid-parse, and the async path. Five of the seven fail without the change.

One thing worth flagging for anyone writing parser tests here:

Assert.Equal("Id", "Id");                    // fails, correctly
Assert.Equal(new[]{"Id"}, new[]{"Id"});      // PASSES under xUnit 2.4.1

The collection comparer does not catch a leading U+FEFF. My first version of these tests compared parser.Record as an array and four of them passed with and without the fix. They now assert field by field, where the comparison is strict. Same trap would hide any test about invisible characters in this suite.

Suite

1066 passed, 4 failed on net8.0. The 4 are pre-existing and environmental — the Linux newline/culture failures reported in #2375 — and I verified the failing set is byte-identical with and without this change, so nothing here masks or causes them.

.NET SDK 10.0.400, Debian 12.

A StreamReader consumes the BOM while detecting the encoding, so this only
shows up when the text arrives already decoded: a StringReader over an API
response, a database column, a decompressed buffer.

Left in place the mark joins the first field. Unquoted it is silent, and
the first header becomes "Id", so mapping by name misses that column.
Quoted it throws BadDataException, because the field then starts with a
character before its opening quote:

    new StringReader("\"username\";\"email\"")
    -> CsvHelper.BadDataException

The mark is now dropped from the buffer on the first fill rather than
skipped over, so positions, counts and RawRecord all read as though it was
never sent, matching what a StreamReader would have produced. Only the very
start of the input qualifies: a U+FEFF anywhere else is data and is kept.

Tests cover quoted and unquoted first fields, RawRecord, a mark inside the
document, a document with no mark, a buffer small enough to refill mid
document, and the async path. Five of the seven fail without the change.

Fields are asserted one at a time rather than comparing the whole record:
Assert.Equal on two string[] does not catch a leading U+FEFF under xUnit
2.4.1, so an array comparison passes with or without the fix.

Fixes JoshClose#2311
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

In case a csv string starts with BOM I get exception

1 participant