From c68d2b59f7450cde902c599d93d07e7f3c15e14b Mon Sep 17 00:00:00 2001 From: Ryan Hendrickson Date: Wed, 4 Oct 2017 12:15:59 -0400 Subject: [PATCH] fix: improve quoting for run-script arguments --- lib/run-script.js | 15 +- node_modules/puka/LICENSE.txt | 18 + node_modules/puka/README.md | 401 ++++++++++++++++++ node_modules/puka/index.js | 738 +++++++++++++++++++++++++++++++++ node_modules/puka/package.json | 72 ++++ package-lock.json | 5 + package.json | 2 + test/tap/run-script.js | 16 +- 8 files changed, 1252 insertions(+), 15 deletions(-) create mode 100644 node_modules/puka/LICENSE.txt create mode 100644 node_modules/puka/README.md create mode 100644 node_modules/puka/index.js create mode 100644 node_modules/puka/package.json diff --git a/lib/run-script.js b/lib/run-script.js index 3302576311719..ae5bb8cda70a6 100644 --- a/lib/run-script.js +++ b/lib/run-script.js @@ -10,6 +10,9 @@ var usage = require('./utils/usage') var output = require('./utils/output.js') var didYouMean = require('./utils/did-you-mean') var isWindowsShell = require('./utils/is-windows-shell.js') +var puka = require('puka') +var sh = puka.sh +var unquoted = puka.unquoted runScript.usage = usage( 'run-script', @@ -166,20 +169,10 @@ function run (pkg, wd, cmd, args, cb) { chain(cmds.map(function (c) { // pass cli arguments after -- to script. if (pkg.scripts[c] && c === cmd) { - pkg.scripts[c] = pkg.scripts[c] + joinArgs(args) + pkg.scripts[c] = sh`${unquoted(pkg.scripts[c])} ${args}` } // when running scripts explicitly, assume that they're trusted. return [lifecycle, pkg, c, wd, { unsafePerm: true }] }), cb) } - -// join arguments after '--' and pass them to script, -// handle special characters such as ', ", ' '. -function joinArgs (args) { - var joinedArgs = '' - args.forEach(function (arg) { - joinedArgs += ' "' + arg.replace(/"/g, '\\"') + '"' - }) - return joinedArgs -} diff --git a/node_modules/puka/LICENSE.txt b/node_modules/puka/LICENSE.txt new file mode 100644 index 0000000000000..0141196a59337 --- /dev/null +++ b/node_modules/puka/LICENSE.txt @@ -0,0 +1,18 @@ +Copyright 2017 Ryan Hendrickson + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/node_modules/puka/README.md b/node_modules/puka/README.md new file mode 100644 index 0000000000000..3c36811ac3f64 --- /dev/null +++ b/node_modules/puka/README.md @@ -0,0 +1,401 @@ +# Puka + +[![GitLab CI pipeline status](https://gitlab.com/rhendric/puka/badges/master/pipeline.svg)](https://gitlab.com/rhendric/puka/commits/master) [![AppVeyor build status](https://img.shields.io/appveyor/ci/rhendric/puka.svg?label=windows%20tests)](https://ci.appveyor.com/project/rhendric/puka) [![Codecov status](https://img.shields.io/codecov/c/gl/rhendric/puka.svg)](https://codecov.io/gl/rhendric/puka) + +Puka is a cross-platform library for safely passing strings through shells. + +#### Contents + +- [Introduction](#introduction) + - [Why would I use Puka?](#why-would-i-use-puka) + - [How do I use Puka?](#how-do-i-use-puka) + - [What's the catch?](#whats-the-catch) +- [API Documentation](#api-documentation) + - [Basic API](#basic-api) + - [sh](#sh) + - [unquoted](#unquoted) + - [Advanced API](#advanced-api) + - [quoteForShell](#quoteforshell) + - [quoteForCmd](#quoteforcmd) + - [quoteForSh](#quoteforsh) + - [ShellString](#shellstring) + - [Secret API](#secret-api) +- [The sh DSL](#the-sh-dsl) + - [Syntax](#syntax) + - [Semantics](#semantics) + - [Types of placeholders](#types-of-placeholders) + +## Introduction + +### Why would I use Puka? + +When launching a child process from Node, you have a choice between launching +directly from the operating system (as with [child_process.spawn](https://nodejs.org/api/child_process.html#child_process_child_process_spawn_command_args_options), +if you don't use the `{ shell: true }` option), and running the command through +a shell (as with [child_process.exec](https://nodejs.org/api/child_process.html#child_process_child_process_exec_command_options_callback)). +Using a shell gives you more power, such as the ability to chain multiple +commands together or use redirection, but you have to construct your command as +a single string instead of using an array of arguments. And doing that can be +buggy (if not dangerous) if you don't take care to quote any arguments +correctly for the shell you're targeting, _and_ the quoting has to be done +differently on Windows and non-Windows shells. + +Puka solves that problem by giving you a simple and platform-agnostic way to +build shell commands with arguments that pass through your shell unaltered and +with no unsafe side effects, **whether you are running on Windows or a +Unix-based OS**. + +### How do I use Puka? + +Puka gives you an `sh` function intended for tagging +[template literals](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals), +which quotes (if necessary) any values interpolated into the template. A simple +example: + +```javascript +const { sh } = require('puka'); +const { execSync } = require('child_process'); + +const arg = 'file with spaces.txt'; +execSync(sh`some-command ${arg}`); +``` + +But Puka supports more than this! See [the `sh` DSL documentation](#the-sh-dsl) +for a detailed description of all the features currently supported. + +### What's the catch? + +Here are the ones I know about: + +Puka does _not_ ensure that the actual commands you're running are +cross-platform. If you're running npm programs, you generally won't have a +problem with that, but if you want to run ``sh`cat file` `` on Windows, you'll +need to depend on something like +[cash-cat](https://www.npmjs.com/package/cash-cat). + +I searched for days for a way to quote or escape line breaks in arguments to +`cmd.exe`, but couldn't find one (regular `^`-prepending and quotation marks +don't seem to cut it). If you know of a way that works, please [open an +issue](https://gitlab.com/rhendric/puka/issues/new) to tell me about it! Until +then, any line break characters (`\r` or `\n`) in values being interpolated by +`sh` will cause an error to be thrown on Windows only. + +Also on Windows, you may notice quoting mistakes if you run commands that +involve piping to or from a native executable (not a batch file ending in `.cmd` +or `.bat`). Unfortunately, the combination of batch files and pipes requires +some extra escaping on Windows, and Puka assumes all programs are batch files +because npm creates batch file shims for programs it installs (and, if you care +about cross-platform, you'll be using npm programs in your commands). If this +causes problems for you, please [open an +issue](https://gitlab.com/rhendric/puka/issues/new); if your situation is +specific enough, there may be workarounds or improvements to Puka to be found. + +## API Documentation + +### Basic API + + + + +#### sh + +A string template tag for safely constructing cross-platform shell commands. + +An `sh` template is not actually treated as a literal string to be +interpolated; instead, it is a tiny DSL designed to make working with shell +strings safe, simple, and straightforward. To get started quickly, see the +examples below. [More detailed documentation](#the-sh-dsl) is available +further down. + +**Examples** + +```javascript +const title = '"this" & "that"'; +sh`script --title=${title}`; // => "script '--title=\"this\" & \"that\"'" +// Note: these examples show results for non-Windows platforms. +// On Windows, the above would instead be +// 'script ^"--title=\\^"this\\^" ^& \\^"that\\^"^"'. + +const names = ['file1', 'file 2']; +sh`rimraf ${names}.txt`; // => "rimraf file1.txt 'file 2.txt'" + +const cmd1 = ['cat', 'file 1.txt', 'file 2.txt']; +const cmd2 = ['use-input', '-abc']; +sh`${cmd1}|${cmd2}`; // => "cat 'file 1.txt' 'file 2.txt'|use-input -abc" +``` + +Returns **[String](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String)** a string formatted for the platform Node is currently +running on. + +#### unquoted + +This function permits raw strings to be interpolated into a `sh` template. + +**IMPORTANT**: If you're using Puka due to security concerns, make sure you +don't pass any untrusted content to `unquoted`. This may be obvious, but +stray punctuation in an `unquoted` section can compromise the safety of the +entire shell command. + +**Parameters** + +- `value` any value (it will be treated as a string) + +**Examples** + +```javascript +const both = true; +sh`foo ${unquoted(both ? '&&' : '||')} bar`; // => 'foo && bar' +``` + +### Advanced API + +If these functions make life easier for you, go ahead and use them; they +are just as well supported as the above. But if you aren't certain you +need them, you probably don't. + + +#### quoteForShell + +Quotes a string for injecting into a shell command. + +This function is exposed for some hypothetical case when the `sh` DSL simply +won't do; `sh` is expected to be the more convenient option almost always. +Compare: + +```javascript +console.log('cmd' + args.map(a => ' ' + quoteForShell(a)).join('')); +console.log(sh`cmd ${args}`); // same as above + +console.log('cmd' + args.map(a => ' ' + quoteForShell(a, true)).join('')); +console.log(sh`cmd "${args}"`); // same as above +``` + +Additionally, on Windows, `sh` checks the entire command string for pipes, +which subtly change how arguments need to be quoted. If your commands may +involve pipes, you are strongly encouraged to use `sh` and not try to roll +your own with `quoteForShell`. + +**Parameters** + +- `text` **[String](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String)** to be quoted +- `forceQuote` **[Boolean](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Boolean)?** whether to always add quotes even if the string + is already safe. Defaults to `false`. +- `platform` **[String](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String)?** a value that `process.platform` might take: + `'win32'`, `'linux'`, etc.; determines how the string is to be formatted. + When omitted, effectively the same as `process.platform`. + +Returns **[String](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String)** a string that is safe for the current (or specified) +platform. + +#### quoteForCmd + +A Windows-specific version of [quoteForShell](#quoteforshell). + +**Parameters** + +- `text` **[String](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String)** to be quoted +- `forceQuote` **[Boolean](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Boolean)?** whether to always add quotes even if the string + is already safe. Defaults to `false`. + +#### quoteForSh + +A Unix-specific version of [quoteForShell](#quoteforshell). + +**Parameters** + +- `text` **[String](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String)** to be quoted +- `forceQuote` **[Boolean](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Boolean)?** whether to always add quotes even if the string + is already safe. Defaults to `false`. + +#### ShellString + +A ShellString represents a shell command after it has been interpolated, but +before it has been formatted for a particular platform. ShellStrings are +useful if you want to prepare a command for a different platform than the +current one, for instance. + +To create a ShellString, use `ShellString.sh` the same way you would use +top-level `sh`. + +##### toString + +A method to format a ShellString into a regular String formatted for a +particular platform. + +**Parameters** + +- `platform` **[String](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String)?** a value that `process.platform` might take: + `'win32'`, `'linux'`, etc.; determines how the string is to be formatted. + When omitted, effectively the same as `process.platform`. + +Returns **[String](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String)** + +##### sh + +`ShellString.sh` is a template tag just like `sh`; the only difference is +that this function returns a ShellString which has not yet been formatted +into a String. + +Returns **[ShellString](#shellstring)** + +### Secret API + +Some internals of string formatting have been exposed for the ambitious and +brave souls who want to try to extend Puka to handle more shells or custom +interpolated values. This ‘secret’ API is partially documented in the code +but not here, and the semantic versioning guarantees on this API are bumped +down by one level: in other words, minor version releases of Puka can change +the secret API in backward-incompatible ways, and patch releases can add or +deprecate functionality. + +If it's not even documented in the code, use at your own risk—no semver +guarantees apply. + + +## The sh DSL + +### Syntax + +An `sh` template comprises words, separated by whitespace. Words can contain: + +- text, which is composed of any characters that are not whitespace, single or + double quotes, or any of the special characters + ``# $ & ( ) ; < > \ ` |``; +- quotations, which are matching single or double quotes surrounding any + characters other than the delimiting quote; and +- placeholders, using the standard JavaScript template syntax (`${}`). + (Placeholders may also appear inside quotations.) + +The special characters ``# $ & ( ) ; < > \ ` |``, if unquoted, form their own +words. + +Redirect operators (`<`, `>`, `>>`, `2>`, etc.) receive their own special +handling, as do semicolons. Other than these two exceptions, no attempt is made +to understand any more sophisticated features of shell syntax. + +Standard JavaScript escape sequences, such as `\t`, are honored in the template +literal, and are treated equivalently to the characters they represent. There +is no further mechanism for escaping within the `sh` DSL itself; in particular, +if you want to put quotes inside quotes, you have to use interpolation, like +this: + +```javascript +sh`echo "${'single = \', double = "'}"` // => "echo 'single = '\\'', double = \"'" +``` + +### Semantics + +Words that do not contain placeholders are emitted mostly verbatim to the +output string. Quotations are formatted in the expected style for the target +platform (single quotes for Unix, double quotes for Windows) regardless of the +quotes used in the template literal—as with JavaScript, single and double quotes +are interchangeable, except for the requirement to pair like with like. Unquoted +semicolons are translated to ampersands on Windows; all other special characters +(as enumerated above), when unquoted, are passed as-is to the output for the +shell to interpret. + +Puka may still quote words not containing the above special characters, if they +contain characters that need quoting on the target platform. For example, on +Windows, the character `%` is used for variable interpolation in `cmd.exe`, and +Puka quotes it on on that platform even if it appears unquoted in the template +literal. Consequently, there is no need to be paranoid about quoting anything +that doesn't look alphanumeric inside a `sh` template literal, for fear of being +burned on a different operating system; anything that matches the definition of +‘text’ above will never need manual quoting. + +#### Types of placeholders + +##### Strings + +If a word contains a string placeholder, then the value of the placeholder is +interpolated into the word and the entire word, if necessary, is quoted. If +the placeholder occurs within quotes, no further quoting is performed: + +```javascript +sh`script --file="${'herp derp'}.txt"`; // => "script --file='herp derp.txt'" +``` + +This behavior can be exploited to force consistent quoting, if desired; but +both of the examples below are safe on all platforms: + +```javascript +const words = ['oneword', 'two words']; +sh`minimal ${words[0]}`; // => "minimal oneword" +sh`minimal ${words[1]}`; // => "minimal 'two words'" +sh`consistent '${words[0]}'`; // => "consistent 'oneword'" +sh`consistent '${words[1]}'`; // => "consistent 'two words'" +``` + +##### Arrays and iterables + +If a word contains a placeholder for an array (or other iterable object), then +the entire word is repeated once for each value in the array, separated by +spaces. If the array is empty, then the word is not emitted at all, and neither +is any leading whitespace. + +```javascript +const files = ['foo', 'bar']; +sh`script ${files}`; // => "script foo bar" +sh`script --file=${files}`; // => "script --file=foo --file=bar" +sh`script --file=${[]}`; // => "script" +``` + +Note that, since special characters are their own words, the pipe operator here +is not repeated: + +```javascript +const cmd = ['script', 'foo', 'bar']; +sh`${cmd}|another-script`; // => "script foo bar|another-script" +``` + +Multiple arrays in the same word generate a Cartesian product: + +```javascript +const names = ['foo', 'bar'], exts = ['log', 'txt']; +// Same word +sh`... ${names}.${exts}`; // => "... foo.log foo.txt bar.log bar.txt" +sh`... "${names} ${exts}"`; // => "... 'foo log' 'foo txt' 'bar log' 'bar txt'" + +// Not the same word (extra space just for emphasis): +sh`... ${names} ${exts}`; // => "... foo bar log txt" +sh`... ${names};${exts}`; // => "... foo bar;log txt" +``` + +Finally, if a placeholder appears in the object of a redirect operator, the +entire redirect is repeated as necessary: + +```javascript +sh`script > ${['foo', 'bar']}.txt`; // => "script > foo.txt > bar.txt" +sh`script > ${[]}.txt`; // => "script" +``` + +##### unquoted + +The `unquoted` function returns a value that will skip being quoted when used +in a placeholder, alone or in an array. + +```javascript +const cmd = 'script < input.txt'; +const fields = ['foo', 'bar']; +sh`${unquoted(cmd)} | json ${fields}`; // => "script < input.txt | json foo bar" +``` + +##### ShellString + +If `ShellString.sh` is used to construct an unformatted ShellString, that value +can be used in a placeholder to insert the contents of the ShellString into the +outer template literal. This is safer than using `unquoted` as in the previous +example, but `unquoted` can be used when all you have is a string from another +(trusted!) source. + +```javascript +const url = 'http://example.com/data.json?x=1&y=2'; +const curl = ShellString.sh`curl -L ${url}`; +const fields = ['foo', 'bar']; +sh`${curl} | json ${fields}`; // => "curl -L 'http://example.com/data.json?x=1&y=2' | json foo bar" +``` + +##### Anything else + +... is treated like a string—namely, a value `x` is equivalent to `'' + x`, if +not in one of the above categories. diff --git a/node_modules/puka/index.js b/node_modules/puka/index.js new file mode 100644 index 0000000000000..b3a4974550b95 --- /dev/null +++ b/node_modules/puka/index.js @@ -0,0 +1,738 @@ +'use strict'; + +Object.defineProperty(exports, '__esModule', { value: true }); + +/** + * Key a method on your object with this symbol and you can get special + * formatting for that value! See ShellStringText, ShellStringUnquoted, or + * shellStringSemicolon for examples. + * @ignore + */ +const formatSymbol = Symbol('format'); +/** + * This symbol is for implementing advanced behaviors like the need for extra + * carets in Windows shell strings that use pipes. If present, it's called in + * an earlier phase than formatSymbol, and is passed a mutable context that can + * be read during the format phase to influence formatting. + * @ignore + */ +const preformatSymbol = Symbol('preformat'); + +// When minimum Node version becomes 6, replace calls to sticky with /.../y and +// inline execFrom. +let stickySupported = true; +try { + new RegExp('', 'y'); +} catch (e) { + stickySupported = false; +} +const sticky = stickySupported ? source => new RegExp(source, 'y') : source => new RegExp(`^(?:${source})`); +const execFrom = stickySupported ? (re, haystack, index) => (re.lastIndex = index, re.exec(haystack)) : (re, haystack, index) => re.exec(haystack.substr(index)); + +function quoteForCmd(text, forceQuote) { + let caretDepth = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : 0; + // See the below blog post for an explanation of this function and + // quoteForWin32: + // https://blogs.msdn.microsoft.com/twistylittlepassagesallalike/2011/04/23/everyone-quotes-command-line-arguments-the-wrong-way/ + if (!text.length) { + return '""'; + } + if (/[\n\r]/.test(text)) { + throw new Error("Line breaks can't be quoted on Windows"); + } + const caretEscape = /["%]/.test(text); + text = quoteForWin32(text, forceQuote || !caretEscape && /[&()<>^|]/.test(text)); + if (caretEscape) { + // See Win32Context for explanation of what caretDepth is for. + do { + text = text.replace(/[\t "%&()<>^|]/g, '^$&'); + } while (caretDepth--); + } + return text; +} +const quoteForWin32 = (text, forceQuote) => forceQuote || /[\t "]/.test(text) ? `"${text.replace(/\\+(?=$|")/g, '$&$&').replace(/"/g, '\\"')}"` : text; +const cmdMetaChars = /[\t\n\r "%&()<>^|]/; +class Win32Context { + constructor() { + this.currentScope = newScope(null); + this.scopesByObject = new Map(); + } + read(text) { + // When cmd.exe pipes to or from a batch file, it spawns a second copy of + // itself to run the inner command. This necessitates doubling up on carets + // so that escaped characters survive both cmd.exe invocations. See: + // https://stackoverflow.com/questions/8192318/why-does-delayed-expansion-fail-when-inside-a-piped-block-of-code#8194279 + // https://ss64.com/nt/syntax-redirection.html + // + // Parentheses can create an additional subshell, requiring additional + // escaping... it's a mess. + // + // So here's what we do about it: we read all unquoted text in a shell + // string and put it through this tiny parser that looks for pipes, + // sequence operators (&, &&, ||), and parentheses. This can't be part of + // the main Puka parsing, because it can be affected by `unquoted(...)` + // values provided at evaluation time. + // + // Then, after associating each thing that needs to be quoted with a scope + // (via `mark()`), we can determine the depth of caret escaping required + // in each scope and pass it (via `Formatter::quote()`) to `quoteForCmd()`. + // + // See also `ShellStringText`, which holds the logic for the previous + // paragraph. + const length = text.length; + for (let pos = 0, match; pos < length;) { + if (match = execFrom(reUnimportant, text, pos)) { + pos += match[0].length; + } + if (pos >= length) break; + if (match = execFrom(reSeqOp, text, pos)) { + this.seq(); + pos += match[0].length; + } else { + const char = text.charCodeAt(pos); + if (char === CARET) { + pos += 2; + } else if (char === QUOTE) { + // If you were foolish enough to leave a dangling quotation mark in + // an unquoted span... you're likely to have bigger problems than + // incorrect escaping. So we just do the simplest thing of looking for + // the end quote only in this piece of text. + pos += execFrom(reNotQuote, text, pos + 1)[0].length + 2; + } else { + if (char === OPEN_PAREN) { + this.enterScope(); + } else if (char === CLOSE_PAREN) { + this.exitScope(); + } else { + // (char === '|') + this.currentScope.depthDelta = 1; + } + pos++; + } + } + } + } + enterScope() { + this.currentScope = newScope(this.currentScope); + } + exitScope() { + this.currentScope = this.currentScope.parent || (this.currentScope.parent = newScope(null)); + } + seq() { + // | binds tighter than sequence operators, so the latter create new sibling + // scopes for future |s to mutate. + this.currentScope = newScope(this.currentScope.parent); + } + mark(obj) { + this.scopesByObject.set(obj, this.currentScope); + } + at(obj) { + return { depth: getDepth(this.scopesByObject.get(obj)) }; + } +} +const getDepth = scope => scope === null ? 0 : scope.depth !== -1 ? scope.depth : scope.depth = getDepth(scope.parent) + scope.depthDelta; +const newScope = parent => ({ parent, depthDelta: 0, depth: -1 }); +const CARET = '^'.charCodeAt(); +const QUOTE = '"'.charCodeAt(); +const OPEN_PAREN = '('.charCodeAt(); +const CLOSE_PAREN = ')'.charCodeAt(); +const reNotQuote = sticky('[^"]*'); +const reSeqOp = sticky('&&?|\\|\\|'); +const reUnimportant = sticky('(?:>&|[^"$&()^|])+'); + +const quoteForSh = (text, forceQuote) => text.length ? forceQuote || shMetaChars.test(text) ? `'${text.replace(/'/g, "'\\''")}'`.replace(/^(?:'')+(?!$)/, '').replace(/\\'''/g, "\\'") : text : "''"; +const shMetaChars = /[\t\n\r "#$&'()*;<>?\\`|~]/; + +/** + * To get a Formatter, call `Formatter.for`. + * + * To create a new Formatter, pass an object to `Formatter.declare`. + * + * To set the global default Formatter, assign to `Formatter.default`. + * + * @class + * @property {Formatter} default - The Formatter to be used when no platform + * is provided—for example, when creating strings with `sh`. + * @ignore + */ +function Formatter() {} +Object.assign(Formatter, /** @lends Formatter */{ + /** + * Gets a Formatter that has been declared for the provided platform, or + * the base `'sh'` formatter if there is no Formatter specific to this + * platform, or the Formatter for the current platform if no specific platform + * is provided. + */ + for(platform) { + return platform == null ? Formatter.default || (Formatter.default = Formatter.for(process.platform)) : Formatter._registry.get(platform) || Formatter._registry.get('sh'); + }, + /** + * Creates a new Formatter or mutates the properties on an existing + * Formatter. The `platform` key on the provided properties object determines + * when the Formatter is retrieved. + */ + declare(props) { + const platform = props && props.platform || 'sh'; + const existingFormatter = Formatter._registry.get(platform); + const formatter = Object.assign(existingFormatter || new Formatter(), props); + formatter.emptyString === void 0 && (formatter.emptyString = formatter.quote('', true)); + existingFormatter || Formatter._registry.set(formatter.platform, formatter); + }, + _registry: new Map(), + prototype: { + platform: 'sh', + quote: quoteForSh, + metaChars: shMetaChars, + hasExtraMetaChars: false, + statementSeparator: ';', + createContext() { + return defaultContext; + } + } +}); +const defaultContext = { + at() {} +}; +Formatter.declare(); +Formatter.declare({ + platform: 'win32', + quote(text, forceQuote, opts) { + return quoteForCmd(text, forceQuote, opts && opts.depth || 0); + }, + metaChars: cmdMetaChars, + hasExtraMetaChars: true, + statementSeparator: '&', + createContext(root) { + const context = new this.Context(); + root[preformatSymbol](context); + return context; + }, + Context: Win32Context +}); + +const isObject = any => any === Object(any); +function memoize(f) { + const cache = new WeakMap(); + return arg => { + let result = cache.get(arg); + if (result === void 0) { + result = f(arg); + cache.set(arg, result); + } + return result; + }; +} + +/** + * Represents a contiguous span of text that may or must be quoted. The contents + * may already contain quoted segments, which will always be quoted. If unquoted + * segments also require quoting, the entire span will be quoted together. + * @ignore + */ +class ShellStringText { + constructor(contents, untested) { + this.contents = contents; + this.untested = untested; + } + [formatSymbol](formatter, context) { + const unformattedContents = this.contents; + const length = unformattedContents.length; + const contents = new Array(length); + for (let i = 0; i < length; i++) { + const c = unformattedContents[i]; + contents[i] = isObject(c) && formatSymbol in c ? c[formatSymbol](formatter) : c; + } + for (let unquoted = true, i = 0; i < length; i++) { + const content = contents[i]; + if (content === null) { + unquoted = !unquoted; + } else { + if (unquoted && (formatter.hasExtraMetaChars || this.untested && this.untested.has(i)) && formatter.metaChars.test(content)) { + return formatter.quote(contents.join(''), false, context.at(this)); + } + } + } + const parts = []; + for (let quoted = null, i = 0; i < length; i++) { + const content = contents[i]; + if (content === null) { + quoted = quoted ? (parts.push(formatter.quote(quoted.join(''), true, context.at(this))), null) : []; + } else { + (quoted || parts).push(content); + } + } + const result = parts.join(''); + return result.length ? result : formatter.emptyString; + } + [preformatSymbol](context) { + context.mark(this); + } +} + +/** + * Represents a contiguous span of text that will not be quoted. + * @ignore + */ +class ShellStringUnquoted { + constructor(value) { + this.value = value; + } + [formatSymbol]() { + return this.value; + } + [preformatSymbol](context) { + context.read(this.value); + } +} + +/** + * Represents a semicolon... or an ampersand, on Windows. + * @ignore + */ +const shellStringSemicolon = { + [formatSymbol](formatter) { + return formatter.statementSeparator; + }, + [preformatSymbol](context) { + context.seq(); + } +}; + +const PLACEHOLDER = {}; +const parse = memoize(templateSpans => { + // These are the token types our DSL can recognize. Their values won't escape + // this function. + const TOKEN_TEXT = 0; + const TOKEN_QUOTE = 1; + const TOKEN_SEMI = 2; + const TOKEN_UNQUOTED = 3; + const TOKEN_SPACE = 4; + const TOKEN_REDIRECT = 5; + const result = []; + let placeholderCount = 0; + let prefix = null; + let onlyPrefixOnce = false; + let contents = []; + let quote = 0; + const lastSpan = templateSpans.length - 1; + for (let spanIndex = 0; spanIndex <= lastSpan; spanIndex++) { + const templateSpan = templateSpans[spanIndex]; + const posEnd = templateSpan.length; + let tokenStart = 0; + if (spanIndex) { + placeholderCount++; + contents.push(PLACEHOLDER); + } + // For each span, we first do a recognizing pass in which we use regular + // expressions to identify the positions of tokens in the text, and then + // a second pass that actually splits the text into the minimum number of + // substrings necessary. + const recognized = []; // [type1, index1, type2, index2...] + let firstWordBreak = -1; + let lastWordBreak = -1; + { + let pos = 0, + match; + while (pos < posEnd) { + if (quote) { + if (match = execFrom(quote === CHAR_SQUO ? reQuotation1 : reQuotation2, templateSpan, pos)) { + recognized.push(TOKEN_TEXT, pos); + pos += match[0].length; + } + if (pos < posEnd) { + recognized.push(TOKEN_QUOTE, pos++); + quote = 0; + } + } else { + if (match = execFrom(reText, templateSpan, pos)) { + const setBreaks = match[1] != null; + setBreaks && firstWordBreak < 0 && (firstWordBreak = pos); + recognized.push(setBreaks ? TOKEN_UNQUOTED : TOKEN_TEXT, pos); + pos += match[0].length; + setBreaks && (lastWordBreak = pos); + } + if (match = execFrom(reRedirectOrSpace, templateSpan, pos)) { + firstWordBreak < 0 && (firstWordBreak = pos); + lastWordBreak = pos; + recognized.push(match[1] ? TOKEN_REDIRECT : TOKEN_SPACE, pos); + pos += match[0].length; + } + const char = templateSpan.charCodeAt(pos); + if (char === CHAR_SEMI) { + firstWordBreak < 0 && (firstWordBreak = pos); + recognized.push(TOKEN_SEMI, pos++); + lastWordBreak = pos; + } else if (char === CHAR_SQUO || char === CHAR_DQUO) { + recognized.push(TOKEN_QUOTE, pos++); + quote = char; + } + } + } + } + // Word breaks are only important if they separate words with placeholders, + // so we can ignore the first/last break if this is the first/last span. + spanIndex === 0 && (firstWordBreak = -1); + spanIndex === lastSpan && (lastWordBreak = posEnd); + // Here begins the second pass mentioned above. This loop runs one more + // iteration than there are tokens in recognized, because it handles tokens + // on a one-iteration delay; hence the i <= iEnd instead of i < iEnd. + const iEnd = recognized.length; + for (let i = 0, type = -1; i <= iEnd; i += 2) { + let typeNext = -1, + pos; + if (i === iEnd) { + pos = posEnd; + } else { + typeNext = recognized[i]; + pos = recognized[i + 1]; + // If the next token is space or redirect, but there's another word + // break in this span, then we can handle that token the same way we + // would handle unquoted text because it isn't being attached to a + // placeholder. + typeNext >= TOKEN_SPACE && pos !== lastWordBreak && (typeNext = TOKEN_UNQUOTED); + } + const breakHere = pos === firstWordBreak || pos === lastWordBreak; + if (pos && (breakHere || typeNext !== type)) { + let value = type === TOKEN_QUOTE ? null : type === TOKEN_SEMI ? shellStringSemicolon : templateSpan.substring(tokenStart, pos); + if (type >= TOKEN_SEMI) { + // This branch handles semicolons, unquoted text, spaces, and + // redirects. shellStringSemicolon is already a formatSymbol object; + // the rest need to be wrapped. + type === TOKEN_SEMI || (value = new ShellStringUnquoted(value)); + // We don't need to check placeholderCount here like we do below; + // that's only relevant during the first word break of the span, and + // because this iteration of the loop is processing the token that + // was checked for breaks in the previous iteration, it will have + // already been handled. For the same reason, prefix is guaranteed to + // be null. + if (contents.length) { + result.push(new ShellStringText(contents, null)); + contents = []; + } + // Only spaces and redirects become prefixes, but not if they've been + // rewritten to unquoted above. + if (type >= TOKEN_SPACE) { + prefix = value; + onlyPrefixOnce = type === TOKEN_SPACE; + } else { + result.push(value); + } + } else { + contents.push(value); + } + tokenStart = pos; + } + if (breakHere) { + if (placeholderCount) { + result.push({ contents, placeholderCount, prefix, onlyPrefixOnce }); + } else { + // There's no prefix to handle in this branch; a prefix prior to this + // span would mean placeholderCount > 0, and a prefix in this span + // can't be created because spaces and redirects get rewritten to + // unquoted before the last word break. + contents.length && result.push(new ShellStringText(contents, null)); + } + placeholderCount = 0;prefix = null;onlyPrefixOnce = false; + contents = []; + } + type = typeNext; + } + } + if (quote) { + throw new SyntaxError(`String is missing a ${String.fromCharCode(quote)} character`); + } + return result; +}); +const CHAR_SEMI = ';'.charCodeAt(); +const CHAR_SQUO = "'".charCodeAt(); +const CHAR_DQUO = '"'.charCodeAt(); +const reQuotation1 = sticky("[^']+"); +const reQuotation2 = sticky('[^"]+'); +const reText = sticky('[^\\s"#$&\'();<>\\\\`|]+|([#$&()\\\\`|]+)'); +const reRedirectOrSpace = sticky('((?:\\s+\\d+|\\s*)[<>]+\\s*)|\\s+'); + +class BitSet { + constructor() { + this.vector = new Int32Array(1); + } + has(n) { + return (this.vector[n >>> 5] & 1 << n) !== 0; + } + add(n) { + const i = n >>> 5, + requiredLength = i + 1; + let vector = this.vector;var _vector = vector; + let length = _vector.length; + if (requiredLength > length) { + while (requiredLength > (length *= 2)); + const oldValues = vector; + vector = new Int32Array(length); + vector.set(oldValues); + this.vector = vector; + } + vector[i] |= 1 << n; + } +} + +function evaluate(template, values) { + values = values.map(toStringishArray); + const children = []; + let valuesStart = 0; + for (let i = 0, iMax = template.length; i < iMax; i++) { + const word = template[i]; + if (formatSymbol in word) { + children.push(word); + continue; + } + const contents = word.contents, + placeholderCount = word.placeholderCount, + prefix = word.prefix, + onlyPrefixOnce = word.onlyPrefixOnce; + const kMax = contents.length; + const valuesEnd = valuesStart + placeholderCount; + const tuples = cartesianProduct(values, valuesStart, valuesEnd); + valuesStart = valuesEnd; + for (let j = 0, jMax = tuples.length; j < jMax; j++) { + const needSpace = j > 0; + const tuple = tuples[j]; + (needSpace || prefix) && children.push(needSpace && (onlyPrefixOnce || !prefix) ? unquotedSpace : prefix); + let interpolatedContents = []; + let untested = null; + let quoting = false; + let tupleIndex = 0; + for (let k = 0; k < kMax; k++) { + const content = contents[k]; + if (content === PLACEHOLDER) { + const value = tuple[tupleIndex++]; + if (quoting) { + interpolatedContents.push(value); + } else { + if (isObject(value) && formatSymbol in value) { + if (interpolatedContents.length) { + children.push(new ShellStringText(interpolatedContents, untested)); + interpolatedContents = []; + untested = null; + } + children.push(value); + } else { + (untested || (untested = new BitSet())).add(interpolatedContents.length); + interpolatedContents.push(value); + } + } + } else { + interpolatedContents.push(content); + content === null && (quoting = !quoting); + } + } + if (interpolatedContents.length) { + children.push(new ShellStringText(interpolatedContents, untested)); + } + } + } + return children; +} +const primToStringish = value => value == null ? '' + value : value; +function toStringishArray(value) { + let array; + switch (true) { + default: + if (isObject(value)) { + if (Array.isArray(value)) { + array = value;break; + } + if (Symbol.iterator in value) { + array = Array.from(value);break; + } + } + array = [value]; + } + return array.map(primToStringish); +} +function cartesianProduct(arrs, start, end) { + const size = end - start; + let resultLength = 1; + for (let i = start; i < end; i++) { + resultLength *= arrs[i].length; + } + if (resultLength > 1e6) { + throw new RangeError("Far too many elements to interpolate"); + } + const result = new Array(resultLength); + const indices = new Array(size).fill(0); + for (let i = 0; i < resultLength; i++) { + const value = result[i] = new Array(size); + for (let j = 0; j < size; j++) { + value[j] = arrs[j + start][indices[j]]; + } + for (let j = size - 1; j >= 0; j--) { + if (++indices[j] < arrs[j + start].length) break; + indices[j] = 0; + } + } + return result; +} +const unquotedSpace = new ShellStringUnquoted(' '); + +/** + * A ShellString represents a shell command after it has been interpolated, but + * before it has been formatted for a particular platform. ShellStrings are + * useful if you want to prepare a command for a different platform than the + * current one, for instance. + * + * To create a ShellString, use `ShellString.sh` the same way you would use + * top-level `sh`. + */ +class ShellString { + /** @hideconstructor */ + constructor(children) { + this.children = children; + } + /** + * `ShellString.sh` is a template tag just like `sh`; the only difference is + * that this function returns a ShellString which has not yet been formatted + * into a String. + * @returns {ShellString} + * @function sh + * @static + * @memberof ShellString + */ + static sh(templateSpans) { + for (var _len = arguments.length, values = Array(_len > 1 ? _len - 1 : 0), _key = 1; _key < _len; _key++) { + values[_key - 1] = arguments[_key]; + } + return new ShellString(evaluate(parse(templateSpans), values)); + } + /** + * A method to format a ShellString into a regular String formatted for a + * particular platform. + * + * @param {String} [platform] a value that `process.platform` might take: + * `'win32'`, `'linux'`, etc.; determines how the string is to be formatted. + * When omitted, effectively the same as `process.platform`. + * @returns {String} + */ + toString(platform) { + return this[formatSymbol](Formatter.for(platform)); + } + [formatSymbol](formatter) { + let context = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : formatter.createContext(this); + return this.children.map(child => child[formatSymbol](formatter, context)).join(''); + } + [preformatSymbol](context) { + const children = this.children; + for (let i = 0, iMax = children.length; i < iMax; i++) { + const child = children[i]; + if (preformatSymbol in child) { + child[preformatSymbol](context); + } + } + } +} + +/** + * A Windows-specific version of {@link quoteForShell}. + * @param {String} text to be quoted + * @param {Boolean} [forceQuote] whether to always add quotes even if the string + * is already safe. Defaults to `false`. + */ + +/** + * A Unix-specific version of {@link quoteForShell}. + * @param {String} text to be quoted + * @param {Boolean} [forceQuote] whether to always add quotes even if the string + * is already safe. Defaults to `false`. + */ + +/** + * Quotes a string for injecting into a shell command. + * + * This function is exposed for some hypothetical case when the `sh` DSL simply + * won't do; `sh` is expected to be the more convenient option almost always. + * Compare: + * + * ```javascript + * console.log('cmd' + args.map(a => ' ' + quoteForShell(a)).join('')); + * console.log(sh`cmd ${args}`); // same as above + * + * console.log('cmd' + args.map(a => ' ' + quoteForShell(a, true)).join('')); + * console.log(sh`cmd "${args}"`); // same as above + * ``` + * + * Additionally, on Windows, `sh` checks the entire command string for pipes, + * which subtly change how arguments need to be quoted. If your commands may + * involve pipes, you are strongly encouraged to use `sh` and not try to roll + * your own with `quoteForShell`. + * + * @param {String} text to be quoted + * @param {Boolean} [forceQuote] whether to always add quotes even if the string + * is already safe. Defaults to `false`. + * @param {String} [platform] a value that `process.platform` might take: + * `'win32'`, `'linux'`, etc.; determines how the string is to be formatted. + * When omitted, effectively the same as `process.platform`. + * + * @returns {String} a string that is safe for the current (or specified) + * platform. + */ +function quoteForShell(text, forceQuote, platform) { + return Formatter.for(platform).quote(text, forceQuote); +} + +/** + * A string template tag for safely constructing cross-platform shell commands. + * + * An `sh` template is not actually treated as a literal string to be + * interpolated; instead, it is a tiny DSL designed to make working with shell + * strings safe, simple, and straightforward. To get started quickly, see the + * examples below. {@link #the-sh-dsl More detailed documentation} is available + * further down. + * + * @name sh + * @example + * const title = '"this" & "that"'; + * sh`script --title=${title}`; // => "script '--title=\"this\" & \"that\"'" + * // Note: these examples show results for non-Windows platforms. + * // On Windows, the above would instead be + * // 'script ^"--title=\\^"this\\^" ^& \\^"that\\^"^"'. + * + * const names = ['file1', 'file 2']; + * sh`rimraf ${names}.txt`; // => "rimraf file1.txt 'file 2.txt'" + * + * const cmd1 = ['cat', 'file 1.txt', 'file 2.txt']; + * const cmd2 = ['use-input', '-abc']; + * sh`${cmd1}|${cmd2}`; // => "cat 'file 1.txt' 'file 2.txt'|use-input -abc" + * + * @returns {String} - a string formatted for the platform Node is currently + * running on. + */ +const sh = function () { + return ShellString.sh.apply(ShellString, arguments).toString(); +}; + +/** + * This function permits raw strings to be interpolated into a `sh` template. + * + * **IMPORTANT**: If you're using Puka due to security concerns, make sure you + * don't pass any untrusted content to `unquoted`. This may be obvious, but + * stray punctuation in an `unquoted` section can compromise the safety of the + * entire shell command. + * + * @param value - any value (it will be treated as a string) + * + * @example + * const both = true; + * sh`foo ${unquoted(both ? '&&' : '||')} bar`; // => 'foo && bar' + */ +const unquoted = value => new ShellStringUnquoted(value); + +exports.Formatter = Formatter; +exports.ShellString = ShellString; +exports.ShellStringText = ShellStringText; +exports.ShellStringUnquoted = ShellStringUnquoted; +exports.quoteForCmd = quoteForCmd; +exports.quoteForSh = quoteForSh; +exports.quoteForShell = quoteForShell; +exports.sh = sh; +exports.shellStringSemicolon = shellStringSemicolon; +exports.formatSymbol = formatSymbol; +exports.preformatSymbol = preformatSymbol; +exports.unquoted = unquoted; diff --git a/node_modules/puka/package.json b/node_modules/puka/package.json new file mode 100644 index 0000000000000..fbd5cd88c8b85 --- /dev/null +++ b/node_modules/puka/package.json @@ -0,0 +1,72 @@ +{ + "_from": "puka", + "_id": "puka@1.0.0", + "_inBundle": false, + "_integrity": "sha512-JOY9vNkLjpwi/CtwsZfGcZZiHb+HfOJjjdz93v6150EPNQgb5JDeImlI48r/kZ5i9bNCSjXpU+eyYIxoujhNLw==", + "_location": "/puka", + "_phantomChildren": {}, + "_requested": { + "type": "tag", + "registry": true, + "raw": "puka", + "name": "puka", + "escapedName": "puka", + "rawSpec": "", + "saveSpec": null, + "fetchSpec": "latest" + }, + "_requiredBy": [ + "#USER", + "/" + ], + "_resolved": "https://registry.npmjs.org/puka/-/puka-1.0.0.tgz", + "_shasum": "1dd92f9f81f6c53390a17529b7aebaa96604ad97", + "_spec": "puka", + "_where": "/home/ryan/Projects/npm", + "author": { + "name": "Ryan Hendrickson", + "email": "ryan.hendrickson@alum.mit.edu" + }, + "bugs": { + "url": "https://gitlab.com/rhendric/puka/issues" + }, + "bundleDependencies": false, + "dependencies": {}, + "deprecated": false, + "description": "A cross-platform library for safely passing strings through shells", + "engines": { + "node": ">=4" + }, + "homepage": "https://gitlab.com/rhendric/puka", + "keywords": [ + "args", + "arguments", + "cmd", + "command", + "command-line", + "cross-platform", + "escape", + "escaping", + "exec", + "linux", + "mac", + "macos", + "osx", + "quote", + "quoting", + "sh", + "shell", + "spawn", + "unix", + "win", + "win32", + "windows" + ], + "license": "MIT", + "name": "puka", + "repository": { + "type": "git", + "url": "git+https://gitlab.com/rhendric/puka.git" + }, + "version": "1.0.0" +} diff --git a/package-lock.json b/package-lock.json index 24b78ee9ecf70..5231c4e408ebd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6433,6 +6433,11 @@ "resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz", "integrity": "sha1-8FKijacOYYkX7wqKw0wa5aaChrM=" }, + "puka": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/puka/-/puka-1.0.0.tgz", + "integrity": "sha512-JOY9vNkLjpwi/CtwsZfGcZZiHb+HfOJjjdz93v6150EPNQgb5JDeImlI48r/kZ5i9bNCSjXpU+eyYIxoujhNLw==" + }, "pump": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", diff --git a/package.json b/package.json index c144ff23b8bc9..e8d5e82a13029 100644 --- a/package.json +++ b/package.json @@ -107,6 +107,7 @@ "pacote": "^8.1.6", "path-is-inside": "~1.0.2", "promise-inflight": "~1.0.1", + "puka": "~1.0.0", "qrcode-terminal": "^0.12.0", "query-string": "^6.1.0", "qw": "~1.0.1", @@ -221,6 +222,7 @@ "pacote", "path-is-inside", "promise-inflight", + "puka", "query-string", "qrcode-terminal", "qw", diff --git a/test/tap/run-script.js b/test/tap/run-script.js index f50a9632851a0..85f78520dd2a7 100644 --- a/test/tap/run-script.js +++ b/test/tap/run-script.js @@ -132,10 +132,6 @@ test('npm run-script with args that contain spaces', function (t) { common.npm(['run-script', 'start', '--', 'hello world'], opts, testOutput.bind(null, t, 'hello world')) }) -test('npm run-script with args that contain single quotes', function (t) { - common.npm(['run-script', 'start', '--', 'they"re awesome'], opts, testOutput.bind(null, t, 'they"re awesome')) -}) - test('npm run-script with args that contain double quotes', function (t) { common.npm(['run-script', 'start', '--', 'what"s "up"?'], opts, testOutput.bind(null, t, 'what"s "up"?')) }) @@ -144,6 +140,18 @@ test('npm run-script with args that contain ticks', function (t) { common.npm(['run-script', 'start', '--', 'what\'s \'up\'?'], opts, testOutput.bind(null, t, 'what\'s \'up\'?')) }) +test('npm run-script with args that contain dollar signs', function (t) { + common.npm(['run-script', 'start', '--', '$PWD'], opts, testOutput.bind(null, t, '$PWD')) +}) + +test('npm run-script with args that contain percents', function (t) { + common.npm(['run-script', 'start', '--', '%CD%'], opts, testOutput.bind(null, t, '%CD%')) +}) + +test('npm run-script with args that end in backslash', function (t) { + common.npm(['run-script', 'start', '--', 'C:\\foo\\'], opts, testOutput.bind(null, t, 'C:\\foo\\')) +}) + test('npm run-script with pre script', function (t) { common.npm(['run-script', 'with-post'], opts, testOutput.bind(null, t, 'main;post')) })