From 9e6612ce35a5e0f770c0bda3415c36b93d2a6549 Mon Sep 17 00:00:00 2001 From: Eric Cornelissen Date: Sat, 4 Apr 2026 23:11:56 +0200 Subject: [PATCH 01/14] Add test coverage for optional backtick prior to flags for PowerShell --- test/fixtures/win.js | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/test/fixtures/win.js b/test/fixtures/win.js index 93938c30..dd306226 100644 --- a/test/fixtures/win.js +++ b/test/fixtures/win.js @@ -4228,6 +4228,32 @@ export const flag = { expected: { unquoted: "a=b", quoted: "'a=b'" }, }, ], + "hyphen (-) + backtick (`)": [ + { + input: "`-a", + expected: { unquoted: "a", quoted: "'a'" }, + }, + { + input: "`-a=b", + expected: { unquoted: "a=b", quoted: "'a=b'" }, + }, + { + input: "`--a", + expected: { unquoted: "a", quoted: "'a'" }, + }, + { + input: "`--a=b", + expected: { unquoted: "a=b", quoted: "'a=b'" }, + }, + { + input: "`---a", + expected: { unquoted: "a", quoted: "'a'" }, + }, + { + input: "`---a=b", + expected: { unquoted: "a=b", quoted: "'a=b'" }, + }, + ], "forward slash (/)": [ { input: "/a", From 226e05d3fd4301d7dd045dfc04d13c3cc21d6b52 Mon Sep 17 00:00:00 2001 From: Eric Cornelissen Date: Sat, 4 Apr 2026 23:33:39 +0200 Subject: [PATCH 02/14] Remove optional backtick prefix from flag protenction for PowerShell Because when escaping the backtick is escaped and thus preserved in the output and when quoting the backtick has no effect (it is not otherwise escaped either). --- src/internal/win/powershell.js | 2 +- test/fixtures/win.js | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/internal/win/powershell.js b/src/internal/win/powershell.js index f39933d1..b8b9fdee 100644 --- a/src/internal/win/powershell.js +++ b/src/internal/win/powershell.js @@ -112,6 +112,6 @@ export function getQuoteFunction() { * @returns {function(string): string} A function to protect against flag injection. */ export function getFlagProtectionFunction() { - const leadingHyphensAndSlashes = new RegExp(/^(?:`?-+|\/+)/); + const leadingHyphensAndSlashes = new RegExp(/^(?:-+|\/+)/); return (arg) => arg.replace(leadingHyphensAndSlashes, ""); } diff --git a/test/fixtures/win.js b/test/fixtures/win.js index e57cb6d3..4fe58540 100644 --- a/test/fixtures/win.js +++ b/test/fixtures/win.js @@ -4307,27 +4307,27 @@ export const flag = { "hyphen (-) + backtick (`)": [ { input: "`-a", - expected: { unquoted: "a", quoted: "'a'" }, + expected: { unquoted: "`-a", quoted: "'`-a'" }, }, { input: "`-a=b", - expected: { unquoted: "a=b", quoted: "'a=b'" }, + expected: { unquoted: "`-a=b", quoted: "'`-a=b'" }, }, { input: "`--a", - expected: { unquoted: "a", quoted: "'a'" }, + expected: { unquoted: "`--a", quoted: "'`--a'" }, }, { input: "`--a=b", - expected: { unquoted: "a=b", quoted: "'a=b'" }, + expected: { unquoted: "`--a=b", quoted: "'`--a=b'" }, }, { input: "`---a", - expected: { unquoted: "a", quoted: "'a'" }, + expected: { unquoted: "`---a", quoted: "'`---a'" }, }, { input: "`---a=b", - expected: { unquoted: "a=b", quoted: "'a=b'" }, + expected: { unquoted: "`---a=b", quoted: "'`---a=b'" }, }, ], "forward slash (/)": [ From 241041ef1e82f2db136379f52d4b5a04508c8f33 Mon Sep 17 00:00:00 2001 From: Eric Cornelissen Date: Sat, 4 Apr 2026 23:38:06 +0200 Subject: [PATCH 03/14] Temporary test job for testing PowerShell behavior --- .github/workflows/checks.yml | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index fd873e8e..d0387a8d 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -8,6 +8,39 @@ on: permissions: read-all jobs: + temp: + runs-on: windows-2025 + steps: + - shell: powershell + run: | + echo 'gh --help' + gh --help + + echo '' + echo 'gh `--help' + gh `--help + + echo '' + echo 'gh ``--help' + gh ``--help + + + echo '' + echo "gh '--help'" + gh '--help' + + echo '' + echo "gh '`--help'" + gh '`--help' + + echo '' + echo "gh '``--help'" + gh '``--help' + + + echo '' + echo "gh ' --help'" + gh ' --help' check: name: ${{ matrix.what }} runs-on: ubuntu-24.04 From ed962762346483ce3b8b170ac9d8226360d4621f Mon Sep 17 00:00:00 2001 From: Eric Cornelissen Date: Sat, 4 Apr 2026 23:55:09 +0200 Subject: [PATCH 04/14] debug --- test/integration/escape/powershell.test.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/test/integration/escape/powershell.test.js b/test/integration/escape/powershell.test.js index 1cab58bd..2d822e57 100644 --- a/test/integration/escape/powershell.test.js +++ b/test/integration/escape/powershell.test.js @@ -17,6 +17,10 @@ runTest(`input is escaped for ${constants.binPowerShellNoExt}`, (t) => { const { expected, input, options } = scenario; const shescape = new Shescape(options); const result = shescape.escape(input); - t.is(result, expected); + t.is( + result, + expected, + `in: |${input}|, actual: |${result}|, expected: |${expected}|`, + ); } }); From bbc53078eecd5a6d9ac568bd4e1b132babea107c Mon Sep 17 00:00:00 2001 From: Eric Cornelissen Date: Sun, 5 Apr 2026 00:08:36 +0200 Subject: [PATCH 05/14] adjust escaping --- .github/workflows/checks.yml | 33 --------------------------------- src/internal/win/powershell.js | 2 ++ 2 files changed, 2 insertions(+), 33 deletions(-) diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index d0387a8d..fd873e8e 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -8,39 +8,6 @@ on: permissions: read-all jobs: - temp: - runs-on: windows-2025 - steps: - - shell: powershell - run: | - echo 'gh --help' - gh --help - - echo '' - echo 'gh `--help' - gh `--help - - echo '' - echo 'gh ``--help' - gh ``--help - - - echo '' - echo "gh '--help'" - gh '--help' - - echo '' - echo "gh '`--help'" - gh '`--help' - - echo '' - echo "gh '``--help'" - gh '``--help' - - - echo '' - echo "gh ' --help'" - gh ' --help' check: name: ${{ matrix.what }} runs-on: ubuntu-24.04 diff --git a/src/internal/win/powershell.js b/src/internal/win/powershell.js index b8b9fdee..f35daf19 100644 --- a/src/internal/win/powershell.js +++ b/src/internal/win/powershell.js @@ -16,6 +16,7 @@ export function getEscapeFunction() { const newlines = new RegExp(/\n/g); const backticks = new RegExp(/`/g); const redirects = new RegExp(/(^|[\s\u0085])([*1-6]?)(>)/g); + const hyphens = new RegExp(/([\s\u0085])-/g); const specials1 = new RegExp(/(^|[\s\u0085])([#\-:<@\]])/g); const specials2 = new RegExp(/([$&'(),;{|}‘’‚‛“”„])/g); @@ -33,6 +34,7 @@ export function getEscapeFunction() { .replace(newlines, " ") .replace(backticks, "``") .replace(redirects, "$1$2`$3") + .replace(hyphens, "$1`-") .replace(specials1, "$1`$2") .replace(specials2, "`$1"); From 0ff0fbe9d8b12a9e9e3e2da46a220bf44ff4b0a4 Mon Sep 17 00:00:00 2001 From: Eric Cornelissen Date: Sun, 5 Apr 2026 13:06:13 +0200 Subject: [PATCH 06/14] more testing --- .github/workflows/checks.yml | 43 +++++++++++++++++++++++++++++++ temp.js | 49 ++++++++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+) create mode 100644 temp.js diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index fd873e8e..8123849f 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -8,6 +8,49 @@ on: permissions: read-all jobs: + temp: + runs-on: windows-2025 + steps: + - name: In shell + shell: powershell + run: | + echo 'gh --help' + gh --help >$null + echo $LASTEXITCODE + + echo '' + echo 'gh `--help' + gh `--help >$null + echo $LASTEXITCODE + + echo '' + echo 'gh ``--help' + gh ``--help >$null + echo $LASTEXITCODE + + + echo '' + echo "gh '--help'" + gh '--help' >$null + echo $LASTEXITCODE + + echo '' + echo "gh '``--help'" + gh '`--help' >$null + echo $LASTEXITCODE + + echo '' + echo "gh '````--help'" + gh '``--help' >$null + echo $LASTEXITCODE + + + echo '' + echo "gh ' --help'" + gh ' --help' >$null + echo $LASTEXITCODE + - name: With Shescape + run: node temp.js check: name: ${{ matrix.what }} runs-on: ubuntu-24.04 diff --git a/temp.js b/temp.js new file mode 100644 index 00000000..7dffc45c --- /dev/null +++ b/temp.js @@ -0,0 +1,49 @@ +import { exec } from "node:child_process"; +import { Shescape } from "shescape"; + +const options = { shell: 'powershell' }; +const shescape = new Shescape({ shell: 'powershell' }); + +exec(`gh ${shescape.escape("--help")}`, (error) => { + if (error) { + console.log("gh --help : error"); + } else { + console.log("gh --help : no error"); + } +}); +exec(`gh ${shescape.escape("`--help")}`, (error) => { + if (error) { + console.log("gh `--help : error"); + } else { + console.log("gh `--help : no error"); + } +}); +exec(`gh ${shescape.escape("``--help")}`, (error) => { + if (error) { + console.log("gh ``--help : error"); + } else { + console.log("gh ``--help : no error"); + } +}); + +exec(`gh ${shescape.quote("--help")}`, (error) => { + if (error) { + console.log("gh '--help': error"); + } else { + console.log("gh '--help': no error"); + } +}); +exec(`gh ${shescape.quote("`--help")}`, (error) => { + if (error) { + console.log("gh '`--help': error"); + } else { + console.log("gh '`--help': no error"); + } +}); +exec(`gh ${shescape.quote("``--help")}`, (error) => { + if (error) { + console.log("gh '``--help': error"); + } else { + console.log("gh '``--help': no error"); + } +}); From 2ffe437576103492ed85e6132eab0c4eeaedfafa Mon Sep 17 00:00:00 2001 From: Eric Cornelissen Date: Sun, 5 Apr 2026 13:10:33 +0200 Subject: [PATCH 07/14] correction --- .github/workflows/checks.yml | 2 ++ temp.js | 16 ++++++++-------- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 8123849f..d8cc7098 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -12,6 +12,7 @@ jobs: runs-on: windows-2025 steps: - name: In shell + if: ${{ always() }} shell: powershell run: | echo 'gh --help' @@ -50,6 +51,7 @@ jobs: gh ' --help' >$null echo $LASTEXITCODE - name: With Shescape + if: ${{ always() }} run: node temp.js check: name: ${{ matrix.what }} diff --git a/temp.js b/temp.js index 7dffc45c..e36f832d 100644 --- a/temp.js +++ b/temp.js @@ -1,24 +1,24 @@ import { exec } from "node:child_process"; import { Shescape } from "shescape"; -const options = { shell: 'powershell' }; -const shescape = new Shescape({ shell: 'powershell' }); +const options = { shell: "powershell" }; +const shescape = new Shescape({ shell: "powershell" }); -exec(`gh ${shescape.escape("--help")}`, (error) => { +exec(`gh ${shescape.escape("--help")}`, options, (error) => { if (error) { console.log("gh --help : error"); } else { console.log("gh --help : no error"); } }); -exec(`gh ${shescape.escape("`--help")}`, (error) => { +exec(`gh ${shescape.escape("`--help")}`, options, (error) => { if (error) { console.log("gh `--help : error"); } else { console.log("gh `--help : no error"); } }); -exec(`gh ${shescape.escape("``--help")}`, (error) => { +exec(`gh ${shescape.escape("``--help")}`, options, options, (error) => { if (error) { console.log("gh ``--help : error"); } else { @@ -26,21 +26,21 @@ exec(`gh ${shescape.escape("``--help")}`, (error) => { } }); -exec(`gh ${shescape.quote("--help")}`, (error) => { +exec(`gh ${shescape.quote("--help")}`, options, (error) => { if (error) { console.log("gh '--help': error"); } else { console.log("gh '--help': no error"); } }); -exec(`gh ${shescape.quote("`--help")}`, (error) => { +exec(`gh ${shescape.quote("`--help")}`, options, (error) => { if (error) { console.log("gh '`--help': error"); } else { console.log("gh '`--help': no error"); } }); -exec(`gh ${shescape.quote("``--help")}`, (error) => { +exec(`gh ${shescape.quote("``--help")}`, options, (error) => { if (error) { console.log("gh '``--help': error"); } else { From e9493fb5c4027f47f3c8319754d4c7a8d9129697 Mon Sep 17 00:00:00 2001 From: Eric Cornelissen Date: Sun, 5 Apr 2026 13:11:34 +0200 Subject: [PATCH 08/14] checkout --- .github/workflows/checks.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index d8cc7098..d9002984 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -11,6 +11,10 @@ jobs: temp: runs-on: windows-2025 steps: + - name: Checkout repository + uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + with: + persist-credentials: false - name: In shell if: ${{ always() }} shell: powershell From 403a2169b3c8bcc7358e1c9c83f5945a1e823533 Mon Sep 17 00:00:00 2001 From: Eric Cornelissen Date: Sun, 5 Apr 2026 13:14:18 +0200 Subject: [PATCH 09/14] deps --- .github/workflows/checks.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index d9002984..b6623aa4 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -56,7 +56,9 @@ jobs: echo $LASTEXITCODE - name: With Shescape if: ${{ always() }} - run: node temp.js + run: | + npm clean-install + node temp.js check: name: ${{ matrix.what }} runs-on: ubuntu-24.04 From a27eec1ed84283fae124fe0abe400d4085645c57 Mon Sep 17 00:00:00 2001 From: Eric Cornelissen Date: Sun, 5 Apr 2026 14:19:54 +0200 Subject: [PATCH 10/14] wip testing --- src/internal/tmp.js | 7 + src/internal/win/cmd.js | 2 +- src/internal/win/no-shell.js | 2 +- src/internal/win/powershell.js | 4 +- temp.js | 80 +++-- test/fixtures/win.js | 527 +++++++++++++++++++++++++++++++++ test/unit/unix/shells.test.js | 15 + test/unit/win/shells.test.js | 15 + 8 files changed, 604 insertions(+), 48 deletions(-) create mode 100644 src/internal/tmp.js diff --git a/src/internal/tmp.js b/src/internal/tmp.js new file mode 100644 index 00000000..fb063d58 --- /dev/null +++ b/src/internal/tmp.js @@ -0,0 +1,7 @@ +export function chain({ escape, flagProtect, quote }) { + return quote(flagProtect(escape(arg))); +} + +export function id(arg) { + return arg; +} diff --git a/src/internal/win/cmd.js b/src/internal/win/cmd.js index da194900..ef782ce6 100644 --- a/src/internal/win/cmd.js +++ b/src/internal/win/cmd.js @@ -70,6 +70,6 @@ export function getQuoteFunction() { * @returns {function(string): string} A function to protect against flag injection. */ export function getFlagProtectionFunction() { - const leadingHyphensAndSlashes = new RegExp(/^(?:-+|\/+)/); + const leadingHyphensAndSlashes = new RegExp(/^(?:-|\/)+/); return (arg) => arg.replace(leadingHyphensAndSlashes, ""); } diff --git a/src/internal/win/no-shell.js b/src/internal/win/no-shell.js index a2cbbc4e..bdb18cb5 100644 --- a/src/internal/win/no-shell.js +++ b/src/internal/win/no-shell.js @@ -49,6 +49,6 @@ export function getQuoteFunction() { * @returns {function(string): string} A function to protect against flag injection. */ export function getFlagProtectionFunction() { - const leadingHyphensAndSlashes = new RegExp(/^(?:-+|\/+)/); + const leadingHyphensAndSlashes = new RegExp(/^(?:-|\/)+/); return (arg) => arg.replace(leadingHyphensAndSlashes, ""); } diff --git a/src/internal/win/powershell.js b/src/internal/win/powershell.js index f35daf19..15034312 100644 --- a/src/internal/win/powershell.js +++ b/src/internal/win/powershell.js @@ -16,7 +16,6 @@ export function getEscapeFunction() { const newlines = new RegExp(/\n/g); const backticks = new RegExp(/`/g); const redirects = new RegExp(/(^|[\s\u0085])([*1-6]?)(>)/g); - const hyphens = new RegExp(/([\s\u0085])-/g); const specials1 = new RegExp(/(^|[\s\u0085])([#\-:<@\]])/g); const specials2 = new RegExp(/([$&'(),;{|}‘’‚‛“”„])/g); @@ -34,7 +33,6 @@ export function getEscapeFunction() { .replace(newlines, " ") .replace(backticks, "``") .replace(redirects, "$1$2`$3") - .replace(hyphens, "$1`-") .replace(specials1, "$1`$2") .replace(specials2, "`$1"); @@ -114,6 +112,6 @@ export function getQuoteFunction() { * @returns {function(string): string} A function to protect against flag injection. */ export function getFlagProtectionFunction() { - const leadingHyphensAndSlashes = new RegExp(/^(?:-+|\/+)/); + const leadingHyphensAndSlashes = new RegExp(/^(?:-|\/)+/); return (arg) => arg.replace(leadingHyphensAndSlashes, ""); } diff --git a/temp.js b/temp.js index e36f832d..4af182fb 100644 --- a/temp.js +++ b/temp.js @@ -1,49 +1,43 @@ -import { exec } from "node:child_process"; +import { execSync } from "node:child_process"; import { Shescape } from "shescape"; const options = { shell: "powershell" }; const shescape = new Shescape({ shell: "powershell" }); -exec(`gh ${shescape.escape("--help")}`, options, (error) => { - if (error) { - console.log("gh --help : error"); - } else { - console.log("gh --help : no error"); - } -}); -exec(`gh ${shescape.escape("`--help")}`, options, (error) => { - if (error) { - console.log("gh `--help : error"); - } else { - console.log("gh `--help : no error"); - } -}); -exec(`gh ${shescape.escape("``--help")}`, options, options, (error) => { - if (error) { - console.log("gh ``--help : error"); - } else { - console.log("gh ``--help : no error"); - } -}); +try { + execSync(`gh ${shescape.escape("--help")}`, options); + console.log("gh --help : no error"); +} catch { + console.log("gh --help : error"); +} +try { + execSync(`gh ${shescape.escape("`--help")}`, options); + console.log("gh `--help : no error"); +} catch { + console.log("gh `--help : error"); +} +try { + execSync(`gh ${shescape.escape("``--help")}`, options, options); + console.log("gh ``--help : no error"); +} catch { + console.log("gh ``--help : error"); +} -exec(`gh ${shescape.quote("--help")}`, options, (error) => { - if (error) { - console.log("gh '--help': error"); - } else { - console.log("gh '--help': no error"); - } -}); -exec(`gh ${shescape.quote("`--help")}`, options, (error) => { - if (error) { - console.log("gh '`--help': error"); - } else { - console.log("gh '`--help': no error"); - } -}); -exec(`gh ${shescape.quote("``--help")}`, options, (error) => { - if (error) { - console.log("gh '``--help': error"); - } else { - console.log("gh '``--help': no error"); - } -}); +try { + execSync(`gh ${shescape.quote("--help")}`, options); + console.log("gh '--help': no error"); +} catch { + console.log("gh '--help': error"); +} +try { + execSync(`gh ${shescape.quote("`--help")}`, options); + console.log("gh '`--help': no error"); +} catch { + console.log("gh '`--help': error"); +} +try { + execSync(`gh ${shescape.quote("``--help")}`, options); + console.log("gh '``--help': no error"); +} catch { + console.log("gh '``--help': error"); +} diff --git a/test/fixtures/win.js b/test/fixtures/win.js index 4fe58540..b223d811 100644 --- a/test/fixtures/win.js +++ b/test/fixtures/win.js @@ -1979,6 +1979,58 @@ export const escape = { expected: "-a", }, ], + "hyphens ('-') + whitespace": [ + { + input: "a -b", + expected: "a -b", + }, + { + input: "a\t-b", + expected: "a\t-b", + }, + { + input: "a\u0085-b", + expected: "a\u0085-b", + }, + { + input: "a -b -c", + expected: "a -b -c", + }, + ], + "hyphens ('-') + forward slash ('/')": [ + { + input: "/-a", + expected: "/-a", + }, + { + input: "-/a", + expected: "-/a", + }, + { + input: "a/-", + expected: "a/-", + }, + { + input: "a-/", + expected: "a-/", + }, + { + input: "a/-b", + expected: "a/-b", + }, + { + input: "a-/b", + expected: "a-/b", + }, + { + input: "a/-b-/c", + expected: "a/-b-/c", + }, + { + input: "a-/b/-c", + expected: "a-/b/-c", + }, + ], "backslashes ('\\')": [ { input: "a\\b", @@ -1997,6 +2049,116 @@ export const escape = { expected: "\\a", }, ], + "backslashes ('\\') + whitespace": [ + { + input: "a b\\c", + expected: "a b\\c", + }, + { + input: "a\\b c", + expected: "a\\b c", + }, + { + input: "a b\\", + expected: "a b\\", + }, + { + input: "a b\\\\", + expected: "a b\\\\", + }, + { + input: "\\a b", + expected: "\\a b", + }, + { + input: " a\\", + expected: " a\\", + }, + { + input: " a\\", + expected: " a\\", + }, + { + input: " a b\\", + expected: " a b\\", + }, + { + input: " a b\\", + expected: " a b\\", + }, + { + input: " \\", + expected: " \\", + }, + { + input: "\\ \\", + expected: "\\ \\", + }, + ], + "forward slash ('/')": [ + { + input: "a/b", + expected: "a/b", + }, + { + input: "a/b/c", + expected: "a/b/c", + }, + { + input: "a/", + expected: "a/", + }, + { + input: "/a", + expected: "/a", + }, + ], + "forward slash ('/')+ whitespace": [ + { + input: "a b/c", + expected: "a b/c", + }, + { + input: "a/b c", + expected: "a/b c", + }, + { + input: "a b/", + expected: "a b/", + }, + { + input: "a b//", + expected: "a b//", + }, + { + input: "/a b", + expected: "/a b", + }, + { + input: " a/", + expected: " a/", + }, + { + input: " a/", + expected: " a/", + }, + { + input: " a b/", + expected: " a b/", + }, + { + input: " a b/", + expected: " a b/", + }, + { + input: " /", + expected: " /", + }, + { + input: "/ /", + expected: "/ /", + }, + ], "colons (':')": [ { input: "a:b", @@ -3251,6 +3413,40 @@ export const escape = { expected: "a` `-b` `-c", }, ], + "hyphens ('-') + forward slash ('/')": [ + { + input: "/-a", + expected: "/-a", + }, + { + input: "-/a", + expected: "`-/a", + }, + { + input: "a/-", + expected: "a/-", + }, + { + input: "a-/", + expected: "a-/", + }, + { + input: "a/-b", + expected: "a/-b", + }, + { + input: "a-/b", + expected: "a-/b", + }, + { + input: "a/-b-/c", + expected: "a/-b-/c", + }, + { + input: "a-/b/-c", + expected: "a-/b/-c", + }, + ], "backslashes ('\\')": [ { input: "a\\b", @@ -3319,6 +3515,70 @@ export const escape = { expected: "\\` \\\\", }, ], + "forward slash ('/')": [ + { + input: "a/b", + expected: "a/b", + }, + { + input: "a/b/c", + expected: "a/b/c", + }, + { + input: "a/", + expected: "a/", + }, + { + input: "/a", + expected: "/a", + }, + ], + "forward slash ('/')+ whitespace": [ + { + input: "a b/c", + expected: "a` b/c", + }, + { + input: "a/b c", + expected: "a/b` c", + }, + { + input: "a b/", + expected: "a` b/", + }, + { + input: "a b//", + expected: "a` b//", + }, + { + input: "/a b", + expected: "/a` b", + }, + { + input: " a/", + expected: "` a/", + }, + { + input: " a/", + expected: "` ` a/", + }, + { + input: " a b/", + expected: "` a` b/", + }, + { + input: " a b/", + expected: "` ` a` b/", + }, + { + input: " /", + expected: "` /", + }, + { + input: "/ /", + expected: "/` /", + }, + ], "colons (':')": [ { input: "a:b", @@ -4382,6 +4642,19 @@ export const flag = { expected: { unquoted: "a//b", quoted: "'a//b'" }, }, ], + "forward slash (/) + backtick (`)": [ + // TODO + ], + "hyphens ('-') + forward slash ('/')": [ + { + input: "/-a", + expected: { unquoted: "a", quoted: "'a'" }, + }, + { + input: "-/a", + expected: { unquoted: "a", quoted: "'a'" }, + }, + ], }, }; @@ -4851,6 +5124,58 @@ export const quote = { expected: '"-a"', }, ], + "hyphens ('-') + whitespace": [ + { + input: "a -b", + expected: '"a -b"', + }, + { + input: "a\t-b", + expected: '"a\t-b"', + }, + { + input: "a\u0085-b", + expected: '"a\u0085-b"', + }, + { + input: "a -b -c", + expected: '"a -b -c"', + }, + ], + "hyphens ('-') + forward slash ('/')": [ + { + input: "/-a", + expected: '"/-a"', + }, + { + input: "-/a", + expected: '"-/a"', + }, + { + input: "a/-", + expected: '"a/-"', + }, + { + input: "a-/", + expected: '"a-/"', + }, + { + input: "a/-b", + expected: '"a/-b"', + }, + { + input: "a-/b", + expected: '"a-/b"', + }, + { + input: "a/-b-/c", + expected: '"a/-b-/c"', + }, + { + input: "a-/b/-c", + expected: '"a-/b/-c"', + }, + ], "backslashes ('\\')": [ { input: "a\\b", @@ -4869,6 +5194,92 @@ export const quote = { expected: '"\\a"', }, ], + "backslashes ('\\') + whitespace": [ + { + input: "a b\\c", + expected: '"a b\\c"', + }, + { + input: "a\\b c", + expected: '"a\\b c"', + }, + { + input: "a b\\", + expected: '"a b\\\\"', + }, + { + input: "a b\\\\", + expected: '"a b\\\\\\\\"', + }, + { + input: "\\a b", + expected: '"\\a b"', + }, + ], + "forward slash ('/')": [ + { + input: "a/b", + expected: '"a/b"', + }, + { + input: "a/b/c", + expected: '"a/b/c"', + }, + { + input: "a/", + expected: '"a/"', + }, + { + input: "/a", + expected: '"/a"', + }, + ], + "forward slash ('/') + whitespace": [ + { + input: "a b/c", + expected: '"a b/c"', + }, + { + input: "a/b c", + expected: '"a/b c"', + }, + { + input: "a b/", + expected: '"a b/"', + }, + { + input: "a b//", + expected: '"a b//"', + }, + { + input: "/a b", + expected: '"/a b"', + }, + { + input: " a/", + expected: '" a/"', + }, + { + input: " a/", + expected: '" a/"', + }, + { + input: " a b/", + expected: '" a b/"', + }, + { + input: " a b/", + expected: '" a b/"', + }, + { + input: " /", + expected: '" /"', + }, + { + input: "/ /", + expected: '"/ /"', + }, + ], "pipes ('|')": [ { input: "a|b", @@ -5481,6 +5892,58 @@ export const quote = { expected: "'-a'", }, ], + "hyphens ('-') + whitespace": [ + { + input: "a -b", + expected: "'a -b'", + }, + { + input: "a\t-b", + expected: "'a\t-b'", + }, + { + input: "a\u0085-b", + expected: "'a\u0085-b'", + }, + { + input: "a -b -c", + expected: "'a -b -c'", + }, + ], + "hyphens ('-') + forward slash ('/')": [ + { + input: "/-a", + expected: "'/-a'", + }, + { + input: "-/a", + expected: "'-/a'", + }, + { + input: "a/-", + expected: "'a/-'", + }, + { + input: "a-/", + expected: "'a-/'", + }, + { + input: "a/-b", + expected: "'a/-b'", + }, + { + input: "a-/b", + expected: "'a-/b'", + }, + { + input: "a/-b-/c", + expected: "'a/-b-/c'", + }, + { + input: "a-/b/-c", + expected: "'a-/b/-c'", + }, + ], "backslashes ('\\')": [ { input: "a\\b", @@ -5521,6 +5984,70 @@ export const quote = { expected: "'\\a b'", }, ], + "forward slash ('/')": [ + { + input: "a/b", + expected: "'a/b'", + }, + { + input: "a/b/c", + expected: "'a/b/c'", + }, + { + input: "a/", + expected: "'a/'", + }, + { + input: "/a", + expected: "'/a'", + }, + ], + "forward slash ('/') + whitespace": [ + { + input: "a b/c", + expected: "'a b/c'", + }, + { + input: "a/b c", + expected: "'a/b c'", + }, + { + input: "a b/", + expected: "'a b/'", + }, + { + input: "a b//", + expected: "'a b//'", + }, + { + input: "/a b", + expected: "'/a b'", + }, + { + input: " a/", + expected: "' a/'", + }, + { + input: " a/", + expected: "' a/'", + }, + { + input: " a b/", + expected: "' a b/'", + }, + { + input: " a b/", + expected: "' a b/'", + }, + { + input: " /", + expected: "' /'", + }, + { + input: "/ /", + expected: "'/ /'", + }, + ], "pipes ('|')": [ { input: "a|b", diff --git a/test/unit/unix/shells.test.js b/test/unit/unix/shells.test.js index b0df2101..5848287b 100644 --- a/test/unit/unix/shells.test.js +++ b/test/unit/unix/shells.test.js @@ -106,6 +106,21 @@ for (const [shellName, shellExports] of Object.entries(shells)) { }, ); + testProp( + `escape+flag protection for ${shellName}`, + [fc.stringMatching(/^-+$/), fc.string()], + (t, prefix, value) => { + const arg = `${prefix}${value}`; + + const escape = shellExports.getEscapeFunction(); + const flagProtect = shellExports.getFlagProtectionFunction(); + + const actual = flagProtect(escape(arg)); + const expected = flagProtect(escape(value)); + t.is(actual, expected, `in '${arg}'`); + }, + ); + test(`flag protection performance for ${shellName}`, macros.duration, { arbitraries: [fc.string({ size: "xlarge" })], maxMillis: 50, diff --git a/test/unit/win/shells.test.js b/test/unit/win/shells.test.js index 934bc279..bded7d09 100644 --- a/test/unit/win/shells.test.js +++ b/test/unit/win/shells.test.js @@ -96,6 +96,21 @@ for (const [shellName, shellExports] of Object.entries(shells)) { setup: shellExports.getFlagProtectionFunction, }); + // testProp( + // `escape+flag protection for ${shellName}`, + // [fc.stringMatching(/^[-\/]+$/), fc.string()], + // (t, prefix, value) => { + // const arg = `${prefix}${value}`; + + // const escape = shellExports.getEscapeFunction(); + // const flagProtect = shellExports.getFlagProtectionFunction(); + + // const actual = flagProtect(escape(arg)); + // const expected = flagProtect(escape(value)); + // t.is(actual, expected, `in '${arg}'`); + // }, + // ); + if (shellExports !== nosh) { for (const { input, expected } of quoteFixtures) { test(macros.quote, { From 78bed307362d3170f823a15512a2d7a7e6de04ef Mon Sep 17 00:00:00 2001 From: Eric Cornelissen Date: Sun, 5 Apr 2026 16:32:46 +0200 Subject: [PATCH 11/14] Rework flag protection Re-implement the internal logic for the flag protection functionality. At a high level, the old implementation is removed and a new one is implemented instead. The reason for removing the old implementation is that it was fundamentally flawed because of a circular relation between escaping and flag protection: escaping might change the input string such that flag protection is necessary and flag protection may change the result of escaping because it changes the input string. This buggy behavior is (hopefully) fixed and has been recorded in the changelog. To address this the "flagProtection" function has been replaced by a function that splits the provided argument into flag-prefix and non-flag-prefix parts and escapes and prunes the argument prefix iteratively [^1] until there are no more empty-after-escaping or flag-prefixes left, at which point the remaining string is escaped (and quoted, if necessary). This also fixes a bug with flag protection that did not require the full rewriting, namely that on Windows a prefix combining `-` and `/` would always leave one or the other, thus leaving flags behind. This bug has been recorded separately in the changelog. This _finally_ makes the dream of having flag protection be a platform (rather than shell) level things be a reality! This change is accompanied by a ton of changes to the test suite that should increase the confidence in the change and new behavior. First and foremost, as this change was triggered by a problem with flag protection on Windows, the test fixtures for windows have been significantly extended focussing on flag-related character (`-` and `/`) and their combination. The changes to the `index.test.js` unit suites is just as a result of lifting the flag protection from the shell to the platform level. The `shells.test.js` similarly have most test related to flag protection removed but did get back tests for the composition of flag protection with the shell specific escaping and quoting logic. Notably, this expands the testing of flag protection beyond fixtures into property tests (testing the metamorphic property that adding a flag prefix to an argument does not affect how the argument is escaped compared to escaping the argument itself). Lastly, this reverts some temporary changes intended for testing Windows stuff in CI. --- [^1]: See the `pathological strings` fixtures for example of why this is necessary. --- .github/workflows/checks.yml | 51 ------ CHANGELOG.md | 3 + src/index.js | 20 +-- src/internal/compose.js | 36 ++++ src/internal/tmp.js | 7 - src/internal/unix.js | 11 ++ src/internal/unix/bash.js | 10 -- src/internal/unix/busybox.js | 10 -- src/internal/unix/csh.js | 10 -- src/internal/unix/dash.js | 20 --- src/internal/unix/no-shell.js | 10 -- src/internal/unix/zsh.js | 20 --- src/internal/win.js | 11 ++ src/internal/win/cmd.js | 10 -- src/internal/win/no-shell.js | 10 -- src/internal/win/powershell.js | 10 -- temp.js | 43 ----- test/compat/regexp-engine/runner.js | 2 - test/compat/regexp-engine/unix.test.js | 9 - test/compat/regexp-engine/win.test.js | 9 - test/fixtures/unix.js | 36 ++++ test/fixtures/win.js | 227 ++++++++++++++++++++++++- test/unit/_macros.js | 40 +++-- test/unit/unix/index.test.js | 22 ++- test/unit/unix/shells.test.js | 71 ++++---- test/unit/win/index.test.js | 22 ++- test/unit/win/shells.test.js | 75 ++++---- 27 files changed, 465 insertions(+), 340 deletions(-) create mode 100644 src/internal/compose.js delete mode 100644 src/internal/tmp.js delete mode 100644 temp.js diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index b6623aa4..fd873e8e 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -8,57 +8,6 @@ on: permissions: read-all jobs: - temp: - runs-on: windows-2025 - steps: - - name: Checkout repository - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 - with: - persist-credentials: false - - name: In shell - if: ${{ always() }} - shell: powershell - run: | - echo 'gh --help' - gh --help >$null - echo $LASTEXITCODE - - echo '' - echo 'gh `--help' - gh `--help >$null - echo $LASTEXITCODE - - echo '' - echo 'gh ``--help' - gh ``--help >$null - echo $LASTEXITCODE - - - echo '' - echo "gh '--help'" - gh '--help' >$null - echo $LASTEXITCODE - - echo '' - echo "gh '``--help'" - gh '`--help' >$null - echo $LASTEXITCODE - - echo '' - echo "gh '````--help'" - gh '``--help' >$null - echo $LASTEXITCODE - - - echo '' - echo "gh ' --help'" - gh ' --help' >$null - echo $LASTEXITCODE - - name: With Shescape - if: ${{ always() }} - run: | - npm clean-install - node temp.js check: name: ${{ matrix.what }} runs-on: ubuntu-24.04 diff --git a/CHANGELOG.md b/CHANGELOG.md index 57743587..2a24f465 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ Versioning]. ## [Unreleased] - Expand support for `--enable-experimental-regexp-engine`. ([#2436]) +- Correct prefix escaping when flag protection is on. ([#2458]) +- Correct flag protection on Windows when prefix mixes `-` and `/`. ([#2458]) ## [2.1.10] - 2026-03-10 @@ -397,6 +399,7 @@ Versioning]. [#2388]: https://github.com/ericcornelissen/shescape/pull/2388 [#2410]: https://github.com/ericcornelissen/shescape/pull/2410 [#2436]: https://github.com/ericcornelissen/shescape/pull/2436 +[#2458]: https://github.com/ericcornelissen/shescape/pull/2458 [552e8ea]: https://github.com/ericcornelissen/shescape/commit/552e8eab56861720b1d4e5474fb65741643358f9 [keep a changelog]: https://keepachangelog.com/en/1.0.0/ [semantic versioning]: https://semver.org/spec/v2.0.0.html diff --git a/src/index.js b/src/index.js index cd62dc7c..3e6c3e12 100644 --- a/src/index.js +++ b/src/index.js @@ -11,6 +11,7 @@ import os from "node:os"; import process from "node:process"; +import { compose } from "./internal/compose.js"; import { parseOptions } from "./internal/options.js"; import { getHelpersByPlatform } from "./internal/platforms.js"; import { checkedToString, ensureArray } from "./internal/reflection.js"; @@ -77,25 +78,16 @@ export class Shescape { const { flagProtection, shellName } = options; const shell = platform.getShellHelpers(shellName); + const flagFn = flagProtection ? platform.getFlagFunction() : undefined; { - const escape = shell.getEscapeFunction(); - if (flagProtection) { - const flagProtect = shell.getFlagProtectionFunction(); - this.#escape = (arg) => flagProtect(escape(arg)); - } else { - this.#escape = escape; - } + const escapeFn = shell.getEscapeFunction(); + this.#escape = compose({ escapeFn, flagFn }); } { - const [escape, quote] = shell.getQuoteFunction(); - if (flagProtection) { - const flagProtect = shell.getFlagProtectionFunction(); - this.#quote = (arg) => quote(flagProtect(escape(arg))); - } else { - this.#quote = (arg) => quote(escape(arg)); - } + const [escapeFn, quoteFn] = shell.getQuoteFunction(); + this.#quote = compose({ escapeFn, flagFn, quoteFn }); } } diff --git a/src/internal/compose.js b/src/internal/compose.js new file mode 100644 index 00000000..eae21808 --- /dev/null +++ b/src/internal/compose.js @@ -0,0 +1,36 @@ +/** + * @overview Provides functionality to compose escaping, quoting, and flag + * protection functions. + * @license MPL-2.0 + */ + +/** + * Compose escape, flag protection, and quoting functions to create a function + * to be used for escaping shell arguments. + * + * If no `flagFn` or `quoteFn` is provided the respective functionality is + * omitted from the resulting function.. + * + * @param {object} fns The functions to compose. + * @param {function(string): string} fns.escapeFn An argument escape. + * @param {function(string): string[]} [fns.flagFn] The flag-based splitter. + * @param {function(string): string} [fns.quoteFn] An argument quoter. + * @returns {function(string): string} A function to escape shell arguments. + */ +export function compose({ escapeFn, flagFn, quoteFn }) { + flagFn ||= (arg) => [arg]; + + const escape = quoteFn + ? (arg) => quoteFn(escapeFn(arg)) + : (arg) => escapeFn(arg); + + return (arg) => { + let [preFlag, , ...rest] = flagFn(arg); + while (rest.length > 0 && escapeFn(preFlag) === "") { + arg = rest.join(""); + [preFlag, , ...rest] = rest; + } + + return escape(arg); + }; +} diff --git a/src/internal/tmp.js b/src/internal/tmp.js deleted file mode 100644 index fb063d58..00000000 --- a/src/internal/tmp.js +++ /dev/null @@ -1,7 +0,0 @@ -export function chain({ escape, flagProtect, quote }) { - return quote(flagProtect(escape(arg))); -} - -export function id(arg) { - return arg; -} diff --git a/src/internal/unix.js b/src/internal/unix.js index 2fbbadf9..1ea6cf3d 100644 --- a/src/internal/unix.js +++ b/src/internal/unix.js @@ -76,6 +76,17 @@ export function getDefaultShell() { return "/bin/sh"; } +/** + * Returns a function to enable protection against flag injection for Unix + * systems. + * + * @returns {function(string): string[]} A function enabling flag protection. + */ +export function getFlagFunction() { + const splitter = /(? arg.split(splitter); +} + /** * Returns the helper functions to handle arguments for use with a particular * shell. diff --git a/src/internal/unix/bash.js b/src/internal/unix/bash.js index 6e8d9ed8..aeb53ef0 100644 --- a/src/internal/unix/bash.js +++ b/src/internal/unix/bash.js @@ -61,13 +61,3 @@ function quoteArg(arg) { export function getQuoteFunction() { return [getQuoteEscapeFunction(), quoteArg]; } - -/** - * Returns a function to protect against flag injection for Bash. - * - * @returns {function(string): string} A function to protect against flag injection. - */ -export function getFlagProtectionFunction() { - const leadingHyphens = new RegExp(/^-+/); - return (arg) => arg.replace(leadingHyphens, ""); -} diff --git a/src/internal/unix/busybox.js b/src/internal/unix/busybox.js index 2b9da80e..9cef7e70 100644 --- a/src/internal/unix/busybox.js +++ b/src/internal/unix/busybox.js @@ -61,13 +61,3 @@ function quoteArg(arg) { export function getQuoteFunction() { return [getQuoteEscapeFunction(), quoteArg]; } - -/** - * Returns a function to protect against flag injection for BusyBox. - * - * @returns {function(string): string} A function to protect against flag injection. - */ -export function getFlagProtectionFunction() { - const leadingHyphens = new RegExp(/^-+/); - return (arg) => arg.replace(leadingHyphens, ""); -} diff --git a/src/internal/unix/csh.js b/src/internal/unix/csh.js index 631c80c4..e0f365d9 100644 --- a/src/internal/unix/csh.js +++ b/src/internal/unix/csh.js @@ -80,13 +80,3 @@ function quoteArg(arg) { export function getQuoteFunction() { return [getQuoteEscapeFunction(), quoteArg]; } - -/** - * Returns a function to protect against flag injection for csh. - * - * @returns {function(string): string} A function to protect against flag injection. - */ -export function getFlagProtectionFunction() { - const leadingHyphens = new RegExp(/^-+/); - return (arg) => arg.replace(leadingHyphens, ""); -} diff --git a/src/internal/unix/dash.js b/src/internal/unix/dash.js index a6587c43..81511113 100644 --- a/src/internal/unix/dash.js +++ b/src/internal/unix/dash.js @@ -60,23 +60,3 @@ function quoteArg(arg) { export function getQuoteFunction() { return [escapeArgForQuoted, quoteArg]; } - -/** - * Remove any prefix from the provided argument that might be interpreted as a - * flag on Unix systems for Dash. - * - * @param {string} arg The argument to update. - * @returns {string} The updated argument. - */ -function stripFlagPrefix(arg) { - return arg.replace(/^-+/gu, ""); -} - -/** - * Returns a function to protect against flag injection for Dash. - * - * @returns {function(string): string} A function to protect against flag injection. - */ -export function getFlagProtectionFunction() { - return stripFlagPrefix; -} diff --git a/src/internal/unix/no-shell.js b/src/internal/unix/no-shell.js index ef6aa2b0..bef873e4 100644 --- a/src/internal/unix/no-shell.js +++ b/src/internal/unix/no-shell.js @@ -42,13 +42,3 @@ function unsupported() { export function getQuoteFunction() { return [unsupported, unsupported]; } - -/** - * Returns a function to protect against flag injection for Unix systems. - * - * @returns {function(string): string} A function to protect against flag injection. - */ -export function getFlagProtectionFunction() { - const leadingHyphens = new RegExp(/^-+/); - return (arg) => arg.replace(leadingHyphens, ""); -} diff --git a/src/internal/unix/zsh.js b/src/internal/unix/zsh.js index 0834f916..15c3f3c1 100644 --- a/src/internal/unix/zsh.js +++ b/src/internal/unix/zsh.js @@ -59,23 +59,3 @@ function quoteArg(arg) { export function getQuoteFunction() { return [escapeArgForQuoted, quoteArg]; } - -/** - * Remove any prefix from the provided argument that might be interpreted as a - * flag on Unix systems for Zsh. - * - * @param {string} arg The argument to update. - * @returns {string} The updated argument. - */ -function stripFlagPrefix(arg) { - return arg.replace(/^-+/gu, ""); -} - -/** - * Returns a function to protect against flag injection for Zsh. - * - * @returns {function(string): string} A function to protect against flag injection. - */ -export function getFlagProtectionFunction() { - return stripFlagPrefix; -} diff --git a/src/internal/win.js b/src/internal/win.js index c06ac6ca..63dfd3da 100644 --- a/src/internal/win.js +++ b/src/internal/win.js @@ -49,6 +49,17 @@ export function getDefaultShell({ env }) { return binCmd; } +/** + * Returns a function to enable protection against flag injection for Windows + * systems. + * + * @returns {function(string): string[]} A function enabling flag protection. + */ +export function getFlagFunction() { + const splitter = /(? arg.split(splitter); +} + /** * Returns the helper functions to handle arguments for use with a particular * shell. diff --git a/src/internal/win/cmd.js b/src/internal/win/cmd.js index ef782ce6..2a4be4dc 100644 --- a/src/internal/win/cmd.js +++ b/src/internal/win/cmd.js @@ -63,13 +63,3 @@ function quoteArg(arg) { export function getQuoteFunction() { return [getQuoteEscapeFunction(), quoteArg]; } - -/** - * Returns a function to protect against flag injection for CMD. - * - * @returns {function(string): string} A function to protect against flag injection. - */ -export function getFlagProtectionFunction() { - const leadingHyphensAndSlashes = new RegExp(/^(?:-|\/)+/); - return (arg) => arg.replace(leadingHyphensAndSlashes, ""); -} diff --git a/src/internal/win/no-shell.js b/src/internal/win/no-shell.js index bdb18cb5..0345d877 100644 --- a/src/internal/win/no-shell.js +++ b/src/internal/win/no-shell.js @@ -42,13 +42,3 @@ function unsupported() { export function getQuoteFunction() { return [unsupported, unsupported]; } - -/** - * Returns a function to protect against flag injection for Windows systems. - * - * @returns {function(string): string} A function to protect against flag injection. - */ -export function getFlagProtectionFunction() { - const leadingHyphensAndSlashes = new RegExp(/^(?:-|\/)+/); - return (arg) => arg.replace(leadingHyphensAndSlashes, ""); -} diff --git a/src/internal/win/powershell.js b/src/internal/win/powershell.js index 15034312..f5f9bcb1 100644 --- a/src/internal/win/powershell.js +++ b/src/internal/win/powershell.js @@ -105,13 +105,3 @@ function quoteArg(arg) { export function getQuoteFunction() { return [getQuoteEscapeFunction(), quoteArg]; } - -/** - * Returns a function to protect against flag injection for PowerShell. - * - * @returns {function(string): string} A function to protect against flag injection. - */ -export function getFlagProtectionFunction() { - const leadingHyphensAndSlashes = new RegExp(/^(?:-|\/)+/); - return (arg) => arg.replace(leadingHyphensAndSlashes, ""); -} diff --git a/temp.js b/temp.js deleted file mode 100644 index 4af182fb..00000000 --- a/temp.js +++ /dev/null @@ -1,43 +0,0 @@ -import { execSync } from "node:child_process"; -import { Shescape } from "shescape"; - -const options = { shell: "powershell" }; -const shescape = new Shescape({ shell: "powershell" }); - -try { - execSync(`gh ${shescape.escape("--help")}`, options); - console.log("gh --help : no error"); -} catch { - console.log("gh --help : error"); -} -try { - execSync(`gh ${shescape.escape("`--help")}`, options); - console.log("gh `--help : no error"); -} catch { - console.log("gh `--help : error"); -} -try { - execSync(`gh ${shescape.escape("``--help")}`, options, options); - console.log("gh ``--help : no error"); -} catch { - console.log("gh ``--help : error"); -} - -try { - execSync(`gh ${shescape.quote("--help")}`, options); - console.log("gh '--help': no error"); -} catch { - console.log("gh '--help': error"); -} -try { - execSync(`gh ${shescape.quote("`--help")}`, options); - console.log("gh '`--help': no error"); -} catch { - console.log("gh '`--help': error"); -} -try { - execSync(`gh ${shescape.quote("``--help")}`, options); - console.log("gh '``--help': no error"); -} catch { - console.log("gh '``--help': error"); -} diff --git a/test/compat/regexp-engine/runner.js b/test/compat/regexp-engine/runner.js index ab81c989..73a633c2 100644 --- a/test/compat/regexp-engine/runner.js +++ b/test/compat/regexp-engine/runner.js @@ -31,8 +31,6 @@ try { unix.testEscape(); unix.testQuote(); -unix.testFlagProtect(); win.testEscape(); win.testQuote(); -win.testFlagProtect(); diff --git a/test/compat/regexp-engine/unix.test.js b/test/compat/regexp-engine/unix.test.js index 91829c77..086d94ac 100644 --- a/test/compat/regexp-engine/unix.test.js +++ b/test/compat/regexp-engine/unix.test.js @@ -36,12 +36,3 @@ export function testQuote() { } } } - -export function testFlagProtect() { - for (const shell of shells) { - for (const arg of args) { - const flagProtect = shell.getFlagProtectionFunction(); - flagProtect(arg); - } - } -} diff --git a/test/compat/regexp-engine/win.test.js b/test/compat/regexp-engine/win.test.js index 813f502e..fcb71a53 100644 --- a/test/compat/regexp-engine/win.test.js +++ b/test/compat/regexp-engine/win.test.js @@ -33,12 +33,3 @@ export function testQuote() { } } } - -export function testFlagProtect() { - for (const shell of shells) { - for (const arg of args) { - const flagProtect = shell.getFlagProtectionFunction(); - flagProtect(arg); - } - } -} diff --git a/test/fixtures/unix.js b/test/fixtures/unix.js index 1ee53a3d..23093545 100644 --- a/test/fixtures/unix.js +++ b/test/fixtures/unix.js @@ -7112,6 +7112,12 @@ export const flag = { expected: { unquoted: "a=b" }, }, ], + "pathological strings": [ + { + input: "\0-\0--help", + expected: { unquoted: "help", quoted: "'help'" }, + }, + ], }, [binBash]: { "sample strings": [ @@ -7206,6 +7212,12 @@ export const flag = { expected: { unquoted: "a=b", quoted: "'a=b'" }, }, ], + "pathological strings": [ + { + input: "\0-\0--help", + expected: { unquoted: "help", quoted: "'help'" }, + }, + ], }, [binBusyBox]: { "sample strings": [ @@ -7300,6 +7312,12 @@ export const flag = { expected: { unquoted: "a=b", quoted: "'a=b'" }, }, ], + "pathological strings": [ + { + input: "\0-\0--help", + expected: { unquoted: "help", quoted: "'help'" }, + }, + ], }, [binCsh]: { "sample strings": [ @@ -7394,6 +7412,12 @@ export const flag = { expected: { unquoted: "a=b", quoted: "'a=b'" }, }, ], + "pathological strings": [ + { + input: "\0-\0--help", + expected: { unquoted: "help", quoted: "'help'" }, + }, + ], }, [binDash]: { "sample strings": [ @@ -7488,6 +7512,12 @@ export const flag = { expected: { unquoted: "a=b", quoted: "'a=b'" }, }, ], + "pathological strings": [ + { + input: "\0-\0--help", + expected: { unquoted: "help", quoted: "'help'" }, + }, + ], }, [binZsh]: { "sample strings": [ @@ -7582,6 +7612,12 @@ export const flag = { expected: { unquoted: "a=b", quoted: "'a=b'" }, }, ], + "pathological strings": [ + { + input: "\0-\0--help", + expected: { unquoted: "help", quoted: "'help'" }, + }, + ], }, }; diff --git a/test/fixtures/win.js b/test/fixtures/win.js index b223d811..a8654af2 100644 --- a/test/fixtures/win.js +++ b/test/fixtures/win.js @@ -4296,6 +4296,32 @@ export const flag = { expected: { unquoted: "a=b" }, }, ], + "hyphen (-) + backtick (`)": [ + { + input: "`-a", + expected: { unquoted: "`-a", quoted: '"`-a"' }, + }, + { + input: "`-a=b", + expected: { unquoted: "`-a=b", quoted: '"`-a=b"' }, + }, + { + input: "`--a", + expected: { unquoted: "`--a", quoted: '"`--a"' }, + }, + { + input: "`--a=b", + expected: { unquoted: "`--a=b", quoted: '"`--a=b"' }, + }, + { + input: "`---a", + expected: { unquoted: "`---a", quoted: '"`---a"' }, + }, + { + input: "`---a=b", + expected: { unquoted: "`---a=b", quoted: '"`---a=b"' }, + }, + ], "forward slash (/)": [ { input: "/a", @@ -4348,6 +4374,64 @@ export const flag = { expected: { unquoted: "a//b" }, }, ], + "forward slash (/) + backtick (`)": [ + { + input: "`/a", + expected: { unquoted: "`/a", quoted: '"`/a"' }, + }, + { + input: "`//a", + expected: { unquoted: "`//a", quoted: '"`//a"' }, + }, + { + input: "`///a", + expected: { unquoted: "`///a", quoted: '"`///a"' }, + }, + ], + "hyphens ('-') + forward slash ('/')": [ + { + input: "/-a", + expected: { unquoted: "a", quoted: "'a'" }, + }, + { + input: "-/a", + expected: { unquoted: "a", quoted: "'a'" }, + }, + { + input: "a/-", + expected: { unquoted: "a/-", quoted: "'a/-'" }, + }, + { + input: "a-/", + expected: { unquoted: "a-/", quoted: "'a-/'" }, + }, + { + input: "a/-b", + expected: { unquoted: "a/-b", quoted: "'a/-b'" }, + }, + { + input: "a-/b", + expected: { unquoted: "a-/b", quoted: "'a-/b'" }, + }, + ], + "pathological strings": [ + { + input: "\0-\0--help", + expected: { unquoted: "help", quoted: '"help"' }, + }, + { + input: "\0/\0--help", + expected: { unquoted: "help", quoted: '"help"' }, + }, + { + input: "\0/\0/h", + expected: { unquoted: "h", quoted: '"h"' }, + }, + { + input: "\0-\0/h", + expected: { unquoted: "h", quoted: '"h"' }, + }, + ], }, [binCmd]: { "sample strings": [ @@ -4430,6 +4514,32 @@ export const flag = { expected: { unquoted: "a=b", quoted: '"a=b"' }, }, ], + "hyphen (-) + backtick (`)": [ + { + input: "`-a", + expected: { unquoted: "`-a", quoted: '"`-a"' }, + }, + { + input: "`-a=b", + expected: { unquoted: "`-a=b", quoted: '"`-a=b"' }, + }, + { + input: "`--a", + expected: { unquoted: "`--a", quoted: '"`--a"' }, + }, + { + input: "`--a=b", + expected: { unquoted: "`--a=b", quoted: '"`--a=b"' }, + }, + { + input: "`---a", + expected: { unquoted: "`---a", quoted: '"`---a"' }, + }, + { + input: "`---a=b", + expected: { unquoted: "`---a=b", quoted: '"`---a=b"' }, + }, + ], "forward slash (/)": [ { input: "/a", @@ -4482,6 +4592,64 @@ export const flag = { expected: { unquoted: "a//b", quoted: '"a//b"' }, }, ], + "forward slash (/) + backtick (`)": [ + { + input: "`/a", + expected: { unquoted: "`/a", quoted: '"`/a"' }, + }, + { + input: "`//a", + expected: { unquoted: "`//a", quoted: '"`//a"' }, + }, + { + input: "`///a", + expected: { unquoted: "`///a", quoted: '"`///a"' }, + }, + ], + "hyphens ('-') + forward slash ('/')": [ + { + input: "/-a", + expected: { unquoted: "a", quoted: '"a"' }, + }, + { + input: "-/a", + expected: { unquoted: "a", quoted: '"a"' }, + }, + { + input: "a/-", + expected: { unquoted: "a/-", quoted: '"a/-"' }, + }, + { + input: "a-/", + expected: { unquoted: "a-/", quoted: '"a-/"' }, + }, + { + input: "a/-b", + expected: { unquoted: "a/-b", quoted: '"a/-b"' }, + }, + { + input: "a-/b", + expected: { unquoted: "a-/b", quoted: '"a-/b"' }, + }, + ], + "pathological strings": [ + { + input: "\0-\0--help", + expected: { unquoted: "help", quoted: '"help"' }, + }, + { + input: "\0/\0--help", + expected: { unquoted: "help", quoted: '"help"' }, + }, + { + input: "\0/\0/h", + expected: { unquoted: "h", quoted: '"h"' }, + }, + { + input: "\0-\0/h", + expected: { unquoted: "h", quoted: '"h"' }, + }, + ], }, [binPowerShell]: { "sample strings": [ @@ -4567,27 +4735,27 @@ export const flag = { "hyphen (-) + backtick (`)": [ { input: "`-a", - expected: { unquoted: "`-a", quoted: "'`-a'" }, + expected: { unquoted: "``-a", quoted: "'`-a'" }, }, { input: "`-a=b", - expected: { unquoted: "`-a=b", quoted: "'`-a=b'" }, + expected: { unquoted: "``-a=b", quoted: "'`-a=b'" }, }, { input: "`--a", - expected: { unquoted: "`--a", quoted: "'`--a'" }, + expected: { unquoted: "``--a", quoted: "'`--a'" }, }, { input: "`--a=b", - expected: { unquoted: "`--a=b", quoted: "'`--a=b'" }, + expected: { unquoted: "``--a=b", quoted: "'`--a=b'" }, }, { input: "`---a", - expected: { unquoted: "`---a", quoted: "'`---a'" }, + expected: { unquoted: "``---a", quoted: "'`---a'" }, }, { input: "`---a=b", - expected: { unquoted: "`---a=b", quoted: "'`---a=b'" }, + expected: { unquoted: "``---a=b", quoted: "'`---a=b'" }, }, ], "forward slash (/)": [ @@ -4643,7 +4811,18 @@ export const flag = { }, ], "forward slash (/) + backtick (`)": [ - // TODO + { + input: "`/a", + expected: { unquoted: "``/a", quoted: "'`/a'" }, + }, + { + input: "`//a", + expected: { unquoted: "``//a", quoted: "'`//a'" }, + }, + { + input: "`///a", + expected: { unquoted: "``///a", quoted: "'`///a'" }, + }, ], "hyphens ('-') + forward slash ('/')": [ { @@ -4654,6 +4833,40 @@ export const flag = { input: "-/a", expected: { unquoted: "a", quoted: "'a'" }, }, + { + input: "a/-", + expected: { unquoted: "a/-", quoted: "'a/-'" }, + }, + { + input: "a-/", + expected: { unquoted: "a-/", quoted: "'a-/'" }, + }, + { + input: "a/-b", + expected: { unquoted: "a/-b", quoted: "'a/-b'" }, + }, + { + input: "a-/b", + expected: { unquoted: "a-/b", quoted: "'a-/b'" }, + }, + ], + "pathological strings": [ + { + input: "\0-\0--help", + expected: { unquoted: "help", quoted: "'help'" }, + }, + { + input: "\0/\0--help", + expected: { unquoted: "help", quoted: "'help'" }, + }, + { + input: "\0/\0/h", + expected: { unquoted: "h", quoted: "'h'" }, + }, + { + input: "\0-\0/h", + expected: { unquoted: "h", quoted: "'h'" }, + }, ], }, }; diff --git a/test/unit/_macros.js b/test/unit/_macros.js index ce045d9f..12f58756 100644 --- a/test/unit/_macros.js +++ b/test/unit/_macros.js @@ -8,6 +8,8 @@ import { performance } from "node:perf_hooks"; import test from "ava"; import fc from "fast-check"; +import { compose } from "../../src/internal/compose.js"; + /** * Transforms a string by replacing control characters with unicode point codes * (e.g. `\u{0000}`) or common text shorthands (e.g. `\t`). @@ -63,7 +65,9 @@ function escapeControlCharacters(string) { export const escape = test.macro({ exec(t, { expected, getEscapeFunction, input }) { const escapeFn = getEscapeFunction(); - const actual = escapeFn(input); + const fn = compose({ escapeFn }); + + const actual = fn(input); t.is(actual, expected); }, title(_, { input, shellName }) { @@ -75,31 +79,41 @@ export const escape = test.macro({ /** * The flag macro tests the behavior of the function returned by the provided - * `getFlagProtectionFunction`. + * `getFlagFunction` when composed with escaping and quoting logic. * * @param {object} t The AVA test object. * @param {object} args The arguments for this function. * @param {string} args.expected The expected escaped string. - * @param {Function} args.getFlagProtectionFunction The flag protector builder. + * @param {Function} args.getEscapeFunction The escape function builder. + * @param {Function} args.getFlagFunction The flag function builder. + * @param {Function} args.getQuoteFunction The quote function builder. * @param {string} args.input The string to be escaped. - * @param {string} args.shellName The name of the shell to test. */ export const flag = test.macro({ - exec(t, { expected, getFlagProtectionFunction, input }) { - const flagProtect = getFlagProtectionFunction(); - const actual = flagProtect(input); + exec( + t, + { expected, getEscapeFunction, getFlagFunction, getQuoteFunction, input }, + ) { + const [escapeFn, quoteFn] = getEscapeFunction + ? [getEscapeFunction(), undefined] + : getQuoteFunction(); + const flagFn = getFlagFunction(); + const fn = compose({ escapeFn, flagFn, quoteFn }); + + const actual = fn(input); t.is(actual, expected); }, - title(_, { input, shellName }) { + title(_, { getEscapeFunction, input, shellName }) { + const when = getEscapeFunction ? "escaping" : "quoting"; input = escapeControlCharacters(input); - return `flag protect '${input}' for ${shellName}`; + return `flag protection when ${when} '${input}' for ${shellName}`; }, }); /** - * The flag macro tests the behavior of the function returned by the provided - * `getFlagProtectionFunction`. + * The duration macro tests the provided function executed within the given time + * limit. * * @param {object} t The AVA test object. * @param {object} args The arguments for this function. @@ -142,7 +156,9 @@ export const duration = test.macro({ export const quote = test.macro({ exec(t, { expected, input, getQuoteFunction }) { const [escapeFn, quoteFn] = getQuoteFunction(); - const actual = quoteFn(escapeFn(input)); + const fn = compose({ escapeFn, quoteFn }); + + const actual = fn(input); t.is(actual, expected); }, title(_, { input, shellName }) { diff --git a/test/unit/unix/index.test.js b/test/unit/unix/index.test.js index 3af05eb5..f60a3766 100644 --- a/test/unit/unix/index.test.js +++ b/test/unit/unix/index.test.js @@ -19,7 +19,7 @@ import * as nosh from "../../../src/internal/unix/no-shell.js"; import * as zsh from "../../../src/internal/unix/zsh.js"; import * as unix from "../../../src/internal/unix.js"; -import { arbitrary, constants } from "./_.js"; +import { arbitrary, constants, macros } from "./_.js"; const shells = [ { module: bash, shellName: constants.binBash }, @@ -123,3 +123,23 @@ testProp( t.false(result); }, ); + +testProp("flag protection function return type", [fc.string()], (t, arg) => { + const flagProtect = unix.getFlagFunction(); + const result = flagProtect(arg); + t.true(Array.isArray(result)); + t.true(result.every((entry) => typeof entry === "string")); +}); + +testProp("flag protection function is stateless", [fc.string()], (t, arg) => { + const flagProtect = unix.getFlagFunction(); + const result1 = flagProtect(arg); + const result2 = flagProtect(arg); + t.deepEqual(result1, result2); +}); + +test("flag protection performance", macros.duration, { + arbitraries: [fc.string({ size: "xlarge" })], + maxMillis: 50, + setup: unix.getFlagFunction, +}); diff --git a/test/unit/unix/shells.test.js b/test/unit/unix/shells.test.js index 5848287b..e87c7c0f 100644 --- a/test/unit/unix/shells.test.js +++ b/test/unit/unix/shells.test.js @@ -7,12 +7,14 @@ import { testProp } from "@fast-check/ava"; import test from "ava"; import * as fc from "fast-check"; +import { compose } from "../../../src/internal/compose.js"; import * as bash from "../../../src/internal/unix/bash.js"; import * as busybox from "../../../src/internal/unix/busybox.js"; import * as csh from "../../../src/internal/unix/csh.js"; import * as dash from "../../../src/internal/unix/dash.js"; import * as nosh from "../../../src/internal/unix/no-shell.js"; import * as zsh from "../../../src/internal/unix/zsh.js"; +import * as unix from "../../../src/internal/unix.js"; import { constants, fixtures, macros } from "./_.js"; @@ -80,53 +82,26 @@ for (const [shellName, shellExports] of Object.entries(shells)) { test(macros.flag, { expected: expected.unquoted, input, - getFlagProtectionFunction: shellExports.getFlagProtectionFunction, + getEscapeFunction: shellExports.getEscapeFunction, + getFlagFunction: unix.getFlagFunction, shellName, }); } testProp( - `${shellName} flag protection function return type`, - [fc.string()], - (t, arg) => { - const flagProtect = shellExports.getFlagProtectionFunction(); - const result = flagProtect(arg); - t.is(typeof result, "string"); - }, - ); - - testProp( - `flag protection function for ${shellName} is stateless`, - [fc.string()], - (t, arg) => { - const flagProtect = shellExports.getFlagProtectionFunction(); - const result1 = flagProtect(arg); - const result2 = flagProtect(arg); - t.is(result1, result2); - }, - ); - - testProp( - `escape+flag protection for ${shellName}`, + `flag protection when escaping for ${shellName}`, [fc.stringMatching(/^-+$/), fc.string()], (t, prefix, value) => { - const arg = `${prefix}${value}`; - - const escape = shellExports.getEscapeFunction(); - const flagProtect = shellExports.getFlagProtectionFunction(); + const escapeFn = shellExports.getEscapeFunction(); + const flagFn = unix.getFlagFunction(); + const fn = compose({ escapeFn, flagFn }); - const actual = flagProtect(escape(arg)); - const expected = flagProtect(escape(value)); - t.is(actual, expected, `in '${arg}'`); + const actual = fn(`${prefix}${value}`); + const expected = fn(value); + t.is(actual, expected); }, ); - test(`flag protection performance for ${shellName}`, macros.duration, { - arbitraries: [fc.string({ size: "xlarge" })], - maxMillis: 50, - setup: shellExports.getFlagProtectionFunction, - }); - if (shellExports !== nosh) { for (const { input, expected } of quoteFixtures) { test(macros.quote, { @@ -172,5 +147,29 @@ for (const [shellName, shellExports] of Object.entries(shells)) { return (arg) => quoteFn(escapeFn(arg)); }, }); + + for (const { input, expected } of flagFixtures) { + test(macros.flag, { + expected: expected.quoted, + input, + getFlagFunction: unix.getFlagFunction, + getQuoteFunction: shellExports.getQuoteFunction, + shellName, + }); + } + + testProp( + `flag protection when quoting for ${shellName}`, + [fc.stringMatching(/^-+$/), fc.string()], + (t, prefix, value) => { + const [escapeFn, quoteFn] = shellExports.getQuoteFunction(); + const flagFn = unix.getFlagFunction(); + const fn = compose({ escapeFn, flagFn, quoteFn }); + + const actual = fn(`${prefix}${value}`); + const expected = fn(value); + t.is(actual, expected); + }, + ); } } diff --git a/test/unit/win/index.test.js b/test/unit/win/index.test.js index eb9f1c27..013a8afc 100644 --- a/test/unit/win/index.test.js +++ b/test/unit/win/index.test.js @@ -17,7 +17,7 @@ import * as nosh from "../../../src/internal/win/no-shell.js"; import * as powershell from "../../../src/internal/win/powershell.js"; import * as win from "../../../src/internal/win.js"; -import { arbitrary, constants } from "./_.js"; +import { arbitrary, constants, macros } from "./_.js"; const shells = [ { module: cmd, shellName: "cmd.exe" }, @@ -168,3 +168,23 @@ testProp( t.false(result); }, ); + +testProp("flag protection function return type", [fc.string()], (t, arg) => { + const flagProtect = win.getFlagFunction(); + const result = flagProtect(arg); + t.true(Array.isArray(result)); + t.true(result.every((entry) => typeof entry === "string")); +}); + +testProp("flag protection function is stateless", [fc.string()], (t, arg) => { + const flagProtect = win.getFlagFunction(); + const result1 = flagProtect(arg); + const result2 = flagProtect(arg); + t.deepEqual(result1, result2); +}); + +test("flag protection performance", macros.duration, { + arbitraries: [fc.string({ size: "xlarge" })], + maxMillis: 50, + setup: win.getFlagFunction, +}); diff --git a/test/unit/win/shells.test.js b/test/unit/win/shells.test.js index bded7d09..986ddab8 100644 --- a/test/unit/win/shells.test.js +++ b/test/unit/win/shells.test.js @@ -7,9 +7,11 @@ import { testProp } from "@fast-check/ava"; import test from "ava"; import * as fc from "fast-check"; +import { compose } from "../../../src/internal/compose.js"; import * as cmd from "../../../src/internal/win/cmd.js"; import * as nosh from "../../../src/internal/win/no-shell.js"; import * as powershell from "../../../src/internal/win/powershell.js"; +import * as win from "../../../src/internal/win.js"; import { constants, fixtures, macros } from "./_.js"; @@ -64,53 +66,26 @@ for (const [shellName, shellExports] of Object.entries(shells)) { test(macros.flag, { expected: expected.unquoted, input, - getFlagProtectionFunction: shellExports.getFlagProtectionFunction, + getEscapeFunction: shellExports.getEscapeFunction, + getFlagFunction: win.getFlagFunction, shellName, }); } testProp( - `${shellName} flag protection function return type`, - [fc.string()], - (t, arg) => { - const flagProtect = shellExports.getFlagProtectionFunction(); - const result = flagProtect(arg); - t.is(typeof result, "string"); - }, - ); + `flag protection when escaping for ${shellName}`, + [fc.stringMatching(/^[-/]+$/), fc.string()], + (t, prefix, value) => { + const escapeFn = shellExports.getEscapeFunction(); + const flagFn = win.getFlagFunction(); + const fn = compose({ escapeFn, flagFn }); - testProp( - `flag protection function for ${shellName} is stateless`, - [fc.string()], - (t, arg) => { - const flagProtect = shellExports.getFlagProtectionFunction(); - const result1 = flagProtect(arg); - const result2 = flagProtect(arg); - t.is(result1, result2); + const actual = fn(`${prefix}${value}`); + const expected = fn(value); + t.is(actual, expected); }, ); - test(`flag protection performance for ${shellName}`, macros.duration, { - arbitraries: [fc.string({ size: "xlarge" })], - maxMillis: 50, - setup: shellExports.getFlagProtectionFunction, - }); - - // testProp( - // `escape+flag protection for ${shellName}`, - // [fc.stringMatching(/^[-\/]+$/), fc.string()], - // (t, prefix, value) => { - // const arg = `${prefix}${value}`; - - // const escape = shellExports.getEscapeFunction(); - // const flagProtect = shellExports.getFlagProtectionFunction(); - - // const actual = flagProtect(escape(arg)); - // const expected = flagProtect(escape(value)); - // t.is(actual, expected, `in '${arg}'`); - // }, - // ); - if (shellExports !== nosh) { for (const { input, expected } of quoteFixtures) { test(macros.quote, { @@ -156,5 +131,29 @@ for (const [shellName, shellExports] of Object.entries(shells)) { return (arg) => quoteFn(escapeFn(arg)); }, }); + + for (const { input, expected } of flagFixtures) { + test(macros.flag, { + expected: expected.quoted, + input, + getFlagFunction: win.getFlagFunction, + getQuoteFunction: shellExports.getQuoteFunction, + shellName, + }); + } + + testProp( + `flag protection when quoting for ${shellName}`, + [fc.stringMatching(/^[-/]+$/), fc.string()], + (t, prefix, value) => { + const [escapeFn, quoteFn] = shellExports.getQuoteFunction(); + const flagFn = win.getFlagFunction(); + const fn = compose({ escapeFn, flagFn, quoteFn }); + + const actual = fn(`${prefix}${value}`); + const expected = fn(value); + t.is(actual, expected); + }, + ); } } From 44bbc78879e7ad09b1782d66329e962a80877faa Mon Sep 17 00:00:00 2001 From: Eric Cornelissen Date: Sun, 5 Apr 2026 18:34:28 +0200 Subject: [PATCH 12/14] adjustments - Revert temporary change. - Remove use of `||=`. - Fix uncaught mutant (`(arg) => [arg]` -> `(arg) => []`). --- src/internal/compose.js | 6 ++++-- test/integration/escape/powershell.test.js | 6 +----- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/internal/compose.js b/src/internal/compose.js index eae21808..2c5dae33 100644 --- a/src/internal/compose.js +++ b/src/internal/compose.js @@ -18,12 +18,14 @@ * @returns {function(string): string} A function to escape shell arguments. */ export function compose({ escapeFn, flagFn, quoteFn }) { - flagFn ||= (arg) => [arg]; - const escape = quoteFn ? (arg) => quoteFn(escapeFn(arg)) : (arg) => escapeFn(arg); + if (!flagFn) { + return escape; + } + return (arg) => { let [preFlag, , ...rest] = flagFn(arg); while (rest.length > 0 && escapeFn(preFlag) === "") { diff --git a/test/integration/escape/powershell.test.js b/test/integration/escape/powershell.test.js index 2d822e57..1cab58bd 100644 --- a/test/integration/escape/powershell.test.js +++ b/test/integration/escape/powershell.test.js @@ -17,10 +17,6 @@ runTest(`input is escaped for ${constants.binPowerShellNoExt}`, (t) => { const { expected, input, options } = scenario; const shescape = new Shescape(options); const result = shescape.escape(input); - t.is( - result, - expected, - `in: |${input}|, actual: |${result}|, expected: |${expected}|`, - ); + t.is(result, expected); } }); From 064e891ce0d0cea1e71928f7492203ae77c3197f Mon Sep 17 00:00:00 2001 From: Eric Cornelissen Date: Tue, 7 Apr 2026 16:14:35 +0200 Subject: [PATCH 13/14] typos --- src/internal/compose.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/internal/compose.js b/src/internal/compose.js index 2c5dae33..8064d25d 100644 --- a/src/internal/compose.js +++ b/src/internal/compose.js @@ -9,10 +9,10 @@ * to be used for escaping shell arguments. * * If no `flagFn` or `quoteFn` is provided the respective functionality is - * omitted from the resulting function.. + * omitted from the resulting function. * * @param {object} fns The functions to compose. - * @param {function(string): string} fns.escapeFn An argument escape. + * @param {function(string): string} fns.escapeFn An argument escaper. * @param {function(string): string[]} [fns.flagFn] The flag-based splitter. * @param {function(string): string} [fns.quoteFn] An argument quoter. * @returns {function(string): string} A function to escape shell arguments. From 655030c12443baeba8f9e1b109b4f92a444eed35 Mon Sep 17 00:00:00 2001 From: Eric Cornelissen Date: Tue, 7 Apr 2026 16:15:45 +0200 Subject: [PATCH 14/14] Expand set of pathological test cases --- test/fixtures/unix.js | 24 ++++++++++++++++++++++++ test/fixtures/win.js | 24 ++++++++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/test/fixtures/unix.js b/test/fixtures/unix.js index 23093545..13d33199 100644 --- a/test/fixtures/unix.js +++ b/test/fixtures/unix.js @@ -7113,6 +7113,10 @@ export const flag = { }, ], "pathological strings": [ + { + input: "--", + expected: { unquoted: "", quoted: "''" }, + }, { input: "\0-\0--help", expected: { unquoted: "help", quoted: "'help'" }, @@ -7213,6 +7217,10 @@ export const flag = { }, ], "pathological strings": [ + { + input: "--", + expected: { unquoted: "", quoted: "''" }, + }, { input: "\0-\0--help", expected: { unquoted: "help", quoted: "'help'" }, @@ -7313,6 +7321,10 @@ export const flag = { }, ], "pathological strings": [ + { + input: "--", + expected: { unquoted: "", quoted: "''" }, + }, { input: "\0-\0--help", expected: { unquoted: "help", quoted: "'help'" }, @@ -7413,6 +7425,10 @@ export const flag = { }, ], "pathological strings": [ + { + input: "--", + expected: { unquoted: "", quoted: "''" }, + }, { input: "\0-\0--help", expected: { unquoted: "help", quoted: "'help'" }, @@ -7513,6 +7529,10 @@ export const flag = { }, ], "pathological strings": [ + { + input: "--", + expected: { unquoted: "", quoted: "''" }, + }, { input: "\0-\0--help", expected: { unquoted: "help", quoted: "'help'" }, @@ -7613,6 +7633,10 @@ export const flag = { }, ], "pathological strings": [ + { + input: "--", + expected: { unquoted: "", quoted: "''" }, + }, { input: "\0-\0--help", expected: { unquoted: "help", quoted: "'help'" }, diff --git a/test/fixtures/win.js b/test/fixtures/win.js index a8654af2..b13d20ac 100644 --- a/test/fixtures/win.js +++ b/test/fixtures/win.js @@ -4415,6 +4415,14 @@ export const flag = { }, ], "pathological strings": [ + { + input: "--", + expected: { unquoted: "", quoted: '""' }, + }, + { + input: "/", + expected: { unquoted: "", quoted: '""' }, + }, { input: "\0-\0--help", expected: { unquoted: "help", quoted: '"help"' }, @@ -4633,6 +4641,14 @@ export const flag = { }, ], "pathological strings": [ + { + input: "--", + expected: { unquoted: "", quoted: '""' }, + }, + { + input: "/", + expected: { unquoted: "", quoted: '""' }, + }, { input: "\0-\0--help", expected: { unquoted: "help", quoted: '"help"' }, @@ -4851,6 +4867,14 @@ export const flag = { }, ], "pathological strings": [ + { + input: "--", + expected: { unquoted: "", quoted: "''" }, + }, + { + input: "/", + expected: { unquoted: "", quoted: "''" }, + }, { input: "\0-\0--help", expected: { unquoted: "help", quoted: "'help'" },