From b0441594ca19f56b19e77628a358d0dd807abcd9 Mon Sep 17 00:00:00 2001 From: ogffx <263369015+ogffx@users.noreply.github.com> Date: Mon, 9 Nov 2026 22:22:21 +0100 Subject: [PATCH] fix: double whole runs of backslashes when escaping arguments The regular expressions introduced in #160 to avoid backtracking match a single backslash, so only one backslash of a run before a double quote or at the end of an argument was doubled. Match whole runs in a single pass instead, which keeps the escaping linear. Fixes #188 Co-Authored-By: Claude Opus 5.5 --- lib/util/escape.js | 19 ++++++++++++------- lib/util/escape.test.js | 30 ++++++++++++++++++++++++++++++ test/index.test.js | 12 ++++++++++++ 3 files changed, 54 insertions(+), 7 deletions(-) create mode 100644 lib/util/escape.test.js diff --git a/lib/util/escape.js b/lib/util/escape.js index 7bf2905..a06a725 100644 --- a/lib/util/escape.js +++ b/lib/util/escape.js @@ -19,15 +19,20 @@ function escapeArgument(arg, doubleEscapeMetaChars) { // Please see https://github.com/moxystudio/node-cross-spawn/pull/160 for more information // Sequence of backslashes followed by a double quote: - // double up all the backslashes and escape the double quote - arg = arg.replace(/(?=(\\+?)?)\1"/g, '$1$1\\"'); - + // double up all the backslashes and escape the double quote. // Sequence of backslashes followed by the end of the string // (which will become a double quote later): - // double up all the backslashes - arg = arg.replace(/(?=(\\+?)?)\1$/, '$1$1'); - - // All other backslashes occur literally + // double up all the backslashes. + // All other backslashes occur literally. + // Whole runs of backslashes are matched first so that the engine never + // rescans a run, which keeps this linear on any input. + arg = arg.replace(/(\\*)(?:(")|($))|\\+/g, (match, backslashes, quote, end) => { + if (quote === undefined && end === undefined) { + return match; + } + + return backslashes + backslashes + (quote ? '\\"' : ''); + }); // Quote the whole thing: arg = `"${arg}"`; diff --git a/lib/util/escape.test.js b/lib/util/escape.test.js new file mode 100644 index 0000000..343d73e --- /dev/null +++ b/lib/util/escape.test.js @@ -0,0 +1,30 @@ +'use strict'; + +const escape = require('./escape'); + +describe('escape.argument', () => { + it('should double the whole run of backslashes before a double quote', () => { + expect(escape.argument('a\\"b')).toBe('^"a\\\\\\^"b^"'); + expect(escape.argument('a\\\\"b')).toBe('^"a\\\\\\\\\\^"b^"'); + expect(escape.argument('a\\\\\\"b')).toBe('^"a\\\\\\\\\\\\\\^"b^"'); + }); + + it('should double the whole run of backslashes at the end of the argument', () => { + expect(escape.argument('a\\')).toBe('^"a\\\\^"'); + expect(escape.argument('a\\\\')).toBe('^"a\\\\\\\\^"'); + expect(escape.argument('a\\\\\\')).toBe('^"a\\\\\\\\\\\\^"'); + }); + + it('should leave other backslashes untouched', () => { + expect(escape.argument('C:\\a\\\\b')).toBe('^"C:\\a\\\\b^"'); + }); + + it('should run in linear time on long runs of backslashes', () => { + const start = Date.now(); + + escape.argument(`${'\\'.repeat(200000)}a`); + escape.argument(`"${'\\'.repeat(200000)}a`); + + expect(Date.now() - start).toBeLessThan(1000); + }); +}); diff --git a/test/index.test.js b/test/index.test.js index 6131378..e715689 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -157,6 +157,18 @@ run.methods.forEach((method) => { expect(stdout).toBe(arg); }); + + it('should keep runs of backslashes when executing `node_modules/.bin/.cmd`', async () => { + mkdirp.sync(`${__dirname}/fixtures/node_modules/.bin`); + fs.writeFileSync(`${__dirname}/fixtures/node_modules/.bin/echo-cmd-shim.cmd`, + fs.readFileSync(`${__dirname}/fixtures/echo-cmd-shim.cmd`)); + + const args = ['a\\\\', 'x\\\\" --foo \\\\', 'C:\\a\\\\', 'bar']; + + const { stdout } = await run(method, `${__dirname}/fixtures/node_modules/.bin/echo-cmd-shim`, args); + + expect(stdout).toBe(args.join('\n')); + }); } it('should handle commands with names of environment variables', async () => {