From b3647d44b013633884ad594a876a5f2e088c226e Mon Sep 17 00:00:00 2001 From: Sskift <163287349+Sskift@users.noreply.github.com> Date: Tue, 3 Nov 2026 01:21:29 +0800 Subject: [PATCH 1/3] fix(csv-parse): align UTF-16LE comment matching --- packages/csv-parse/lib/api/index.js | 8 ++ .../csv-parse/test/option.comment.utf16le.js | 92 +++++++++++++++++++ 2 files changed, 100 insertions(+) create mode 100644 packages/csv-parse/test/option.comment.utf16le.js diff --git a/packages/csv-parse/lib/api/index.js b/packages/csv-parse/lib/api/index.js index ca97838cd..d0cfc2fb6 100644 --- a/packages/csv-parse/lib/api/index.js +++ b/packages/csv-parse/lib/api/index.js @@ -784,6 +784,14 @@ const transform = function (original_options = {}) { }, __compareBytes: function (sourceBuf, targetBuf, targetPos, firstByte) { if (sourceBuf[0] !== firstByte) return 0; + // UTF-16LE comments must start on a code unit, including after buffering + // an odd number of bytes or discovering the encoding from a BOM. + if ( + /^(?:utf-?16le|ucs-?2)$/i.test(this.options.encoding) && + (this.state.bufBytesStart + targetPos) % 2 !== 0 + ) { + return 0; + } const sourceLength = sourceBuf.length; for (let i = 1; i < sourceLength; i++) { if (sourceBuf[i] !== targetBuf[targetPos + i]) return 0; diff --git a/packages/csv-parse/test/option.comment.utf16le.js b/packages/csv-parse/test/option.comment.utf16le.js new file mode 100644 index 000000000..f3c7f3861 --- /dev/null +++ b/packages/csv-parse/test/option.comment.utf16le.js @@ -0,0 +1,92 @@ +import assert from "node:assert/strict"; +import { parse } from "../lib/index.js"; +import { parse as parseSync } from "../lib/sync.js"; + +describe("Option `comment` with UTF-16LE", function () { + for (const encoding of ["utf16le", "utf-16le", "ucs2", "ucs-2", "UTF16LE"]) { + it(`ignores unaligned comment bytes with ${encoding}`, function () { + // The bytes 23 00 span two characters, rather than encoding a '#'. + const value = "\u2301\u0100"; + const input = Buffer.from(`${value},b# ignored|# comment|c,d`, encoding); + assert.deepEqual( + parseSync(input, { encoding, comment: "#", record_delimiter: "|" }), + [ + [value, "b"], + ["c", "d"], + ], + ); + }); + } + + it("ignores unaligned multi-character comments", function () { + const value = "\u2301\u2100\u0100"; + assert.deepEqual( + parseSync(Buffer.from(`${value},b#! ignored|c,d`, "utf16le"), { + encoding: "utf16le", + comment: "#!", + record_delimiter: "|", + }), + [ + [value, "b"], + ["c", "d"], + ], + ); + }); + + it("uses the BOM-detected encoding with byte-sized writes", function (next) { + const value = "\u2301\u0100"; + const input = Buffer.from(`\ufeff${value},b# ignored|c,d`, "utf16le"); + const parser = parse( + { bom: true, comment: "#", record_delimiter: "|" }, + (err, records) => { + if (err) return next(err); + assert.deepEqual(records, [ + [value, "b"], + ["c", "d"], + ]); + next(); + }, + ); + for (let i = 0; i < input.length; i++) { + parser.write(input.subarray(i, i + 1)); + } + parser.end(); + }); + + it("uses the absolute byte position across chunk boundaries", async function () { + const value = "prefix\u2301\u0100suffix"; + const input = Buffer.from(`${value},b# ignored|c,d`, "utf16le"); + for (let split = 1; split < input.length; split++) { + await new Promise((resolve, reject) => { + const parser = parse( + { encoding: "utf16le", comment: "#", record_delimiter: "|" }, + (err, records) => { + if (err) return reject(err); + try { + assert.deepEqual(records, [ + [value, "b"], + ["c", "d"], + ]); + resolve(); + } catch (error) { + reject(error); + } + }, + ); + parser.write(input.subarray(0, split)); + parser.end(input.subarray(split)); + }); + } + }); + + it("continues to match raw binary comment buffers at any byte position", function () { + assert.deepEqual( + parseSync(Buffer.from("a#\0 ignored|c"), { + encoding: null, + comment: Buffer.from("#\0"), + record_delimiter: "|", + }), + [[Buffer.from("a")], [Buffer.from("c")]], + ); + }); +}); From b1125ff16a69dd5df8447652cf6386b8dd9e6105 Mon Sep 17 00:00:00 2001 From: Sskift <163287349+Sskift@users.noreply.github.com> Date: Tue, 3 Nov 2026 01:42:08 +0800 Subject: [PATCH 2/3] fix(csv-parse): also align UTF-16LE quote and escape matching --- packages/csv-parse/lib/api/index.js | 21 +++--- .../csv-parse/test/option.quote.utf16le.js | 64 +++++++++++++++++++ 2 files changed, 75 insertions(+), 10 deletions(-) create mode 100644 packages/csv-parse/test/option.quote.utf16le.js diff --git a/packages/csv-parse/lib/api/index.js b/packages/csv-parse/lib/api/index.js index d0cfc2fb6..ce60d8506 100644 --- a/packages/csv-parse/lib/api/index.js +++ b/packages/csv-parse/lib/api/index.js @@ -782,16 +782,17 @@ const transform = function (original_options = {}) { } return [undefined, field]; }, - __compareBytes: function (sourceBuf, targetBuf, targetPos, firstByte) { - if (sourceBuf[0] !== firstByte) return 0; - // UTF-16LE comments must start on a code unit, including after buffering + __isCharBoundary: function (pos) { + // UTF-16LE tokens must start on a code unit, including after buffering // an odd number of bytes or discovering the encoding from a BOM. - if ( - /^(?:utf-?16le|ucs-?2)$/i.test(this.options.encoding) && - (this.state.bufBytesStart + targetPos) % 2 !== 0 - ) { + return ( + !/^(?:utf-?16le|ucs-?2)$/i.test(this.options.encoding) || + (this.state.bufBytesStart + pos) % 2 === 0 + ); + }, + __compareBytes: function (sourceBuf, targetBuf, targetPos, firstByte) { + if (sourceBuf[0] !== firstByte || !this.__isCharBoundary(targetPos)) return 0; - } const sourceLength = sourceBuf.length; for (let i = 1; i < sourceLength; i++) { if (sourceBuf[i] !== targetBuf[targetPos + i]) return 0; @@ -849,7 +850,7 @@ const transform = function (original_options = {}) { return false; } } - return true; + return this.__isCharBoundary(pos); } return false; }, @@ -871,7 +872,7 @@ const transform = function (original_options = {}) { return false; } } - return true; + return this.__isCharBoundary(pos); }, __isRecordDelimiter: function (chr, buf, pos) { const { record_delimiter } = this.options; diff --git a/packages/csv-parse/test/option.quote.utf16le.js b/packages/csv-parse/test/option.quote.utf16le.js new file mode 100644 index 000000000..30e58a3c3 --- /dev/null +++ b/packages/csv-parse/test/option.quote.utf16le.js @@ -0,0 +1,64 @@ +import assert from "node:assert/strict"; +import { parse } from "../lib/index.js"; +import { parse as parseSync } from "../lib/sync.js"; + +describe("UTF-16LE quote and escape boundaries", function () { + const quoteValue = "\u2201\u0100"; + const escapeValue = "\u5c01\u0100"; + for (const encoding of ["utf16le", "utf-16le", "ucs2", "ucs-2", "UTF16LE"]) { + for (const quoted of [false, true]) { + it(`preserves unaligned quote bytes with ${encoding} and quoted=${quoted}`, function () { + const field = quoted ? `"${quoteValue}"` : quoteValue; + assert.deepEqual( + parseSync(Buffer.from(`${field},b`, encoding), { encoding }), + [[quoteValue, "b"]], + ); + }); + } + } + + it("preserves unaligned custom escape bytes", function () { + assert.deepEqual( + parseSync(Buffer.from(`"${escapeValue}",b`, "utf16le"), { + encoding: "utf16le", + escape: "\\", + }), + [[escapeValue, "b"]], + ); + }); + + it("uses absolute positions across every stream split", async function () { + const input = Buffer.from( + `"prefix${quoteValue}${escapeValue}",b`, + "utf16le", + ); + for (let split = 1; split < input.length; split++) { + const records = await new Promise((resolve, reject) => { + const parser = parse( + { encoding: "utf16le", escape: "\\" }, + (err, records) => { + if (err) return reject(err); + resolve(records); + }, + ); + parser.write(input.subarray(0, split)); + parser.end(input.subarray(split)); + }); + assert.deepEqual(records, [[`prefix${quoteValue}${escapeValue}`, "b"]]); + } + }); + + it("continues to parse real quotes and escaped quotes", function () { + assert.deepEqual( + parseSync(Buffer.from('"a""b",c', "utf16le"), { encoding: "utf16le" }), + [['a"b', "c"]], + ); + assert.deepEqual( + parseSync(Buffer.from('"a\\"b",c', "utf16le"), { + encoding: "utf16le", + escape: "\\", + }), + [['a"b', "c"]], + ); + }); +}); From f8c3b294afed47462fa95e2e5018df91a95039ef Mon Sep 17 00:00:00 2001 From: Sskift <163287349+Sskift@users.noreply.github.com> Date: Tue, 3 Nov 2026 01:59:23 +0800 Subject: [PATCH 3/3] Avoid encoding checks for UTF-8 and binary input --- packages/csv-parse/lib/api/index.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/csv-parse/lib/api/index.js b/packages/csv-parse/lib/api/index.js index ce60d8506..f17bc1af8 100644 --- a/packages/csv-parse/lib/api/index.js +++ b/packages/csv-parse/lib/api/index.js @@ -785,8 +785,11 @@ const transform = function (original_options = {}) { __isCharBoundary: function (pos) { // UTF-16LE tokens must start on a code unit, including after buffering // an odd number of bytes or discovering the encoding from a BOM. + const { encoding } = this.options; return ( - !/^(?:utf-?16le|ucs-?2)$/i.test(this.options.encoding) || + encoding === "utf8" || + encoding === null || + !/^(?:utf-?16le|ucs-?2)$/i.test(encoding) || (this.state.bufBytesStart + pos) % 2 === 0 ); },