diff --git a/CHANGELOG.md b/CHANGELOG.md index 8aad74a4..c7dd173d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ Versioning]. ## [Unreleased] - Correct identifying inherited `shell` before Node.js v22.0.0. ([#2447]) +- Correct prefix escaping when flag protection is on. ([#2458]) +- Correct flag protection on Windows when prefix mixes `-` and `/`. ([#2458]) - Expand support for `--enable-experimental-regexp-engine`. ([#2436]) ## [2.1.10] - 2026-03-10 @@ -399,6 +401,7 @@ Versioning]. [#2410]: https://github.com/ericcornelissen/shescape/pull/2410 [#2436]: https://github.com/ericcornelissen/shescape/pull/2436 [#2447]: https://github.com/ericcornelissen/shescape/pull/2447 +[#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 7bebb6ce..7e65be2c 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"; @@ -80,25 +81,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..8064d25d --- /dev/null +++ b/src/internal/compose.js @@ -0,0 +1,38 @@ +/** + * @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 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. + */ +export function compose({ escapeFn, flagFn, quoteFn }) { + 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) === "") { + arg = rest.join(""); + [preFlag, , ...rest] = rest; + } + + return escape(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 da194900..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 a2cbbc4e..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 f39933d1..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/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..13d33199 100644 --- a/test/fixtures/unix.js +++ b/test/fixtures/unix.js @@ -7112,6 +7112,16 @@ export const flag = { expected: { unquoted: "a=b" }, }, ], + "pathological strings": [ + { + input: "--", + expected: { unquoted: "", quoted: "''" }, + }, + { + input: "\0-\0--help", + expected: { unquoted: "help", quoted: "'help'" }, + }, + ], }, [binBash]: { "sample strings": [ @@ -7206,6 +7216,16 @@ export const flag = { expected: { unquoted: "a=b", quoted: "'a=b'" }, }, ], + "pathological strings": [ + { + input: "--", + expected: { unquoted: "", quoted: "''" }, + }, + { + input: "\0-\0--help", + expected: { unquoted: "help", quoted: "'help'" }, + }, + ], }, [binBusyBox]: { "sample strings": [ @@ -7300,6 +7320,16 @@ export const flag = { expected: { unquoted: "a=b", quoted: "'a=b'" }, }, ], + "pathological strings": [ + { + input: "--", + expected: { unquoted: "", quoted: "''" }, + }, + { + input: "\0-\0--help", + expected: { unquoted: "help", quoted: "'help'" }, + }, + ], }, [binCsh]: { "sample strings": [ @@ -7394,6 +7424,16 @@ export const flag = { expected: { unquoted: "a=b", quoted: "'a=b'" }, }, ], + "pathological strings": [ + { + input: "--", + expected: { unquoted: "", quoted: "''" }, + }, + { + input: "\0-\0--help", + expected: { unquoted: "help", quoted: "'help'" }, + }, + ], }, [binDash]: { "sample strings": [ @@ -7488,6 +7528,16 @@ export const flag = { expected: { unquoted: "a=b", quoted: "'a=b'" }, }, ], + "pathological strings": [ + { + input: "--", + expected: { unquoted: "", quoted: "''" }, + }, + { + input: "\0-\0--help", + expected: { unquoted: "help", quoted: "'help'" }, + }, + ], }, [binZsh]: { "sample strings": [ @@ -7582,6 +7632,16 @@ export const flag = { expected: { unquoted: "a=b", quoted: "'a=b'" }, }, ], + "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 052ea85b..b13d20ac 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", @@ -4036,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", @@ -4088,6 +4374,72 @@ 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: "--", + expected: { unquoted: "", quoted: '""' }, + }, + { + input: "/", + expected: { unquoted: "", quoted: '""' }, + }, + { + 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": [ @@ -4170,6 +4522,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", @@ -4222,6 +4600,72 @@ 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: "--", + expected: { unquoted: "", quoted: '""' }, + }, + { + input: "/", + expected: { unquoted: "", quoted: '""' }, + }, + { + 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": [ @@ -4304,6 +4748,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", @@ -4356,6 +4826,72 @@ 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: "--", + expected: { unquoted: "", quoted: "''" }, + }, + { + input: "/", + expected: { unquoted: "", quoted: "''" }, + }, + { + 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'" }, + }, + ], }, }; @@ -4825,6 +5361,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", @@ -4843,6 +5431,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", @@ -5455,6 +6129,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", @@ -5495,6 +6221,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/_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 b0df2101..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,38 +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"); - }, - ); + `flag protection when escaping for ${shellName}`, + [fc.stringMatching(/^-+$/), fc.string()], + (t, prefix, value) => { + const escapeFn = shellExports.getEscapeFunction(); + const flagFn = unix.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, - }); - if (shellExports !== nosh) { for (const { input, expected } of quoteFixtures) { test(macros.quote, { @@ -157,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 934bc279..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,38 +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, - }); - if (shellExports !== nosh) { for (const { input, expected } of quoteFixtures) { test(macros.quote, { @@ -141,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); + }, + ); } }