From 25d18cc518fd1fe2be39ddbb6bb85d35aac41822 Mon Sep 17 00:00:00 2001 From: Yarchik Date: Wed, 1 Jul 2026 22:41:02 +0100 Subject: [PATCH 1/2] fix(csv-stringify): double regexp-metacharacter escape/quote literally The escape and quote characters are configurable to any single character, but when a quoted field contained the escape or quote char, the code doubled occurrences via `new RegExp(escape, "g")` / `new RegExp(quote, "g")`. That interpolates the user char into a pattern, so metacharacters misbehave: `|` and `.` match everywhere and inject the doubled char at every position, `*`/`+`/`?` throw "Nothing to repeat", and `^`/`$` anchor and silently no-op. Replace the RegExp doubling with a literal `replaceAll` using a function replacer, so neither the pattern nor a `$` in the replacement is interpreted. This restores the round-trip invariant parse(stringify(x)) === x for any configured escape/quote character. --- packages/csv-stringify/lib/api/index.js | 9 ++---- packages/csv-stringify/test/option.escape.js | 32 ++++++++++++++++++++ 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/packages/csv-stringify/lib/api/index.js b/packages/csv-stringify/lib/api/index.js index 39de6053..75295232 100644 --- a/packages/csv-stringify/lib/api/index.js +++ b/packages/csv-stringify/lib/api/index.js @@ -253,15 +253,10 @@ const stringifier = function (options, state, info) { quotedString || quotedMatch; if (shouldQuote === true && containsEscape === true) { - const regexp = - escape === "\\" - ? new RegExp(escape + escape, "g") - : new RegExp(escape, "g"); - value = value.replace(regexp, escape + escape); + value = value.replaceAll(escape, () => escape + escape); } if (containsQuote === true) { - const regexp = new RegExp(quote, "g"); - value = value.replace(regexp, escape + quote); + value = value.replaceAll(quote, () => escape + quote); } if (shouldQuote === true) { value = quote + value + quote; diff --git a/packages/csv-stringify/test/option.escape.js b/packages/csv-stringify/test/option.escape.js index b4026dc4..e318ebb2 100644 --- a/packages/csv-stringify/test/option.escape.js +++ b/packages/csv-stringify/test/option.escape.js @@ -1,7 +1,39 @@ import "should"; import { stringify } from "../lib/index.js"; +import { stringify as stringifySync } from "../lib/sync.js"; describe("Option `escape`", function () { + it("regexp metacharacter escape is doubled literally", function () { + // The escape character was interpolated into `new RegExp(escape, "g")`. + // Metacharacters (| . * + ? ( [ { ^ $ ...) broke the doubling: "|" and + // "." matched everywhere, "*" threw "Nothing to repeat", "$" anchored. + // A field that must be quoted and contains the escape char must have every + // literal escape occurrence doubled, whatever the character. + stringifySync([["a|b,c"]], { escape: "|", eof: false }).should.eql( + '"a||b,c"', + ); + stringifySync([["a.b,c"]], { escape: ".", eof: false }).should.eql( + '"a..b,c"', + ); + (() => { + stringifySync([["a*b,c"]], { escape: "*", eof: false }); + }).should.not.throw(); + stringifySync([["a*b,c"]], { escape: "*", eof: false }).should.eql( + '"a**b,c"', + ); + // "$" is also special in the replacement string, not only the pattern. + stringifySync([["a$b,c"]], { escape: "$", eof: false }).should.eql( + '"a$$b,c"', + ); + }); + it("regexp metacharacter quote is doubled literally", function () { + // Same class of bug on `new RegExp(quote, "g")` when the quote character is + // a regexp metacharacter and appears inside the field. + stringifySync([["a.b"]], { quote: ".", eof: false }).should.eql('.a".b.'); + stringifySync([["a|b"]], { quote: "|", escape: "\\", eof: false }).should.eql( + "|a\\|b|", + ); + }); it("validation", function () { (() => { stringify([], { escape: true }); From ab76692d2dc5b2bb96790128d1e5bb977802a1d0 Mon Sep 17 00:00:00 2001 From: David Worms Date: Thu, 2 Jul 2026 19:17:28 +0200 Subject: [PATCH 2/2] test(csv-stringify): dispatch 494 tests (cherry picked from commit 662726b438032fbd6e58d33acb005109f9bca1f4) --- packages/csv-stringify/test/option.escape.js | 32 -------------------- packages/csv-stringify/test/option.escape.ts | 26 ++++++++++++++++ packages/csv-stringify/test/option.quote.ts | 15 +++++++++ 3 files changed, 41 insertions(+), 32 deletions(-) diff --git a/packages/csv-stringify/test/option.escape.js b/packages/csv-stringify/test/option.escape.js index e318ebb2..b4026dc4 100644 --- a/packages/csv-stringify/test/option.escape.js +++ b/packages/csv-stringify/test/option.escape.js @@ -1,39 +1,7 @@ import "should"; import { stringify } from "../lib/index.js"; -import { stringify as stringifySync } from "../lib/sync.js"; describe("Option `escape`", function () { - it("regexp metacharacter escape is doubled literally", function () { - // The escape character was interpolated into `new RegExp(escape, "g")`. - // Metacharacters (| . * + ? ( [ { ^ $ ...) broke the doubling: "|" and - // "." matched everywhere, "*" threw "Nothing to repeat", "$" anchored. - // A field that must be quoted and contains the escape char must have every - // literal escape occurrence doubled, whatever the character. - stringifySync([["a|b,c"]], { escape: "|", eof: false }).should.eql( - '"a||b,c"', - ); - stringifySync([["a.b,c"]], { escape: ".", eof: false }).should.eql( - '"a..b,c"', - ); - (() => { - stringifySync([["a*b,c"]], { escape: "*", eof: false }); - }).should.not.throw(); - stringifySync([["a*b,c"]], { escape: "*", eof: false }).should.eql( - '"a**b,c"', - ); - // "$" is also special in the replacement string, not only the pattern. - stringifySync([["a$b,c"]], { escape: "$", eof: false }).should.eql( - '"a$$b,c"', - ); - }); - it("regexp metacharacter quote is doubled literally", function () { - // Same class of bug on `new RegExp(quote, "g")` when the quote character is - // a regexp metacharacter and appears inside the field. - stringifySync([["a.b"]], { quote: ".", eof: false }).should.eql('.a".b.'); - stringifySync([["a|b"]], { quote: "|", escape: "\\", eof: false }).should.eql( - "|a\\|b|", - ); - }); it("validation", function () { (() => { stringify([], { escape: true }); diff --git a/packages/csv-stringify/test/option.escape.ts b/packages/csv-stringify/test/option.escape.ts index d84143b6..85398443 100644 --- a/packages/csv-stringify/test/option.escape.ts +++ b/packages/csv-stringify/test/option.escape.ts @@ -1,6 +1,7 @@ import "should"; import dedent from "dedent"; import { stringify } from "../lib/index.js"; +import { stringify as stringifySync } from "../lib/sync.js"; describe("Option `escape`", function () { it("default", function (next) { @@ -82,4 +83,29 @@ describe("Option `escape`", function () { }, ); }); + + it("regexp metacharacter escape is doubled literally (fix #494)", function () { + // The escape character was interpolated into `new RegExp(escape, "g")`. + // Metacharacters (| . * + ? ( [ { ^ $ ...) broke the doubling: "|" and + // "." matched everywhere, "*" threw "Nothing to repeat", "$" anchored. + // A field that must be quoted and contains the escape char must have every + // literal escape occurrence doubled, whatever the character. + // Before #494, the returned value was `"||a|||||b||,||c||"` + stringifySync([["a|b,c"]], { escape: "|", eof: false }).should.eql( + '"a||b,c"', + ); + // Before #494, the returned value was `..........` + stringifySync([["a.b,c"]], { escape: ".", eof: false }).should.eql( + '"a..b,c"', + ); + // Before #494, an error was thrown `Invalid regular expression: /*/g: Nothing to repeat` + stringifySync([["a*b,c"]], { escape: "*", eof: false }).should.eql( + '"a**b,c"', + ); + // "$" is also special in the replacement string, not only the pattern. + // Before #494, the returned value was `"a$b,c$"` + stringifySync([["a$b,c"]], { escape: "$", eof: false }).should.eql( + '"a$$b,c"', + ); + }); }); diff --git a/packages/csv-stringify/test/option.quote.ts b/packages/csv-stringify/test/option.quote.ts index 851b3150..ca5d52b4 100644 --- a/packages/csv-stringify/test/option.quote.ts +++ b/packages/csv-stringify/test/option.quote.ts @@ -1,6 +1,7 @@ import "should"; import dedent from "dedent"; import { stringify } from "../lib/index.js"; +import { stringify as stringifySync } from "../lib/sync.js"; describe("Option `quote`", function () { it("default", function (next) { @@ -225,4 +226,18 @@ describe("Option `quote`", function () { }, ); }); + + it("regex metacharacter quote is doubled literally (fix #494)", function () { + // See "Option `escape` - regexp metacharacter escape is doubled literally (fix #494)" + // Same class of bug on `new RegExp(quote, "g")` when the quote character is + // a regexp metacharacter and appears inside the field. + // Before #494, the returned value was `."."."..` + stringifySync([["a.b"]], { quote: ".", eof: false }).should.eql('.a".b.'); + // Before #494, the returned value was `|\|a\||\|b\||` + stringifySync([["a|b"]], { + quote: "|", + escape: "\\", + eof: false, + }).should.eql("|a\\|b|"); + }); });