"use strict"; require("array.prototype.flat").shim(); const Promise = require("bluebird"); const util = require("util"); const execFileAsync = util.promisify(require("child_process").execFile); const debug = require("debug")("cvm:execBinary"); const asExpression = require("as-expression"); const splitFilterN = require("split-filter-n"); const { rethrowAs, chain } = require("error-chain"); const isPlainObj = require("is-plain-obj"); const concatArrays = require("concat-arrays"); const unreachable = require("@joepie91/unreachable")("cvm"); // FIXME: Change on publish const textParser = require("../text-parser"); const errors = require("./errors"); const OutputForbidden = Symbol("OutputForbidden"); /* FIXME: How to handle partial result parsing when an error is encountered in the parsing adapter? */ /* FIXME: Test that flag-dash prevention in arguments works */ // FIXME: Explicitly document that text parsers *should* allow for specifying arbitrary postprocessing JS in some way function keyToFlagName(key) { if (key.startsWith("!")) { return key.slice(1); } else if (key.length === 1) { return `-${key}`; } else { return `--${key}`; } } function flagValueToArgs(key, value) { if (value === true) { return [key]; } else if (Array.isArray(value)) { return value.map((item) => { return flagValueToArgs(key, item); }).flat(); } else { return [key, value]; } } function flagsToArgs(flags) { return Object.keys(flags).map((key) => { let value = flags[key]; let flagName = keyToFlagName(key); return flagValueToArgs(flagName, value); }).flat(); } function validateArguments(args) { if (args.some((arg) => arg == null)) { throw new Error("One or more arguments were undefined or null; this is probably a mistake in how you're calling the command"); } else if (args.some((arg) => arg[0] === "-")) { throw new Error("For security reasons, command arguments cannot start with a dash; use the 'withFlags' method if you want to specify flags"); } } function testExitCode(exitCode, allowedExitCodes) { if (allowedExitCodes === null) { // NOTE: The `===` here is intentional; *only* a null is considered to mean "any exit code allowed", so that when an `undefined` gets passed in accidentally, it doesn't silently do the wrong thing. return true; } else { return allowedExitCodes.includes(exitCode); } } function tryExpectation(expectation, channels) { let channelName = expectation.channel; let channel = channels[channelName]; let channelAsString = channel.toString(); if (channel != null) { if (expectation.adapter === OutputForbidden && channelAsString.length > 0) { throw new errors.UnexpectedOutput(`Encountered output on '${channelName}', but no output was supposed to be produced there`, { failedChannel: channelName }); } else { let result = asExpression(() => { try { return expectation.adapter.parse(channelAsString); } catch (error) { if (error instanceof textParser.ParseError) { throw error; } else { throw chain(error, errors.OutputParsingFailed, `An error occurred while parsing '${channelName}'`, { failedChannel: expectation.channel }); } } }); if (textParser.isErrorResult(result)) { result.throw(); // } else if (result === undefined || isPlainObj(result)) { // NOTE: Currently broken, see https://github.com/sindresorhus/is-plain-obj/issues/11 } else if (result === undefined || (typeof result === "object" && !Array.isArray(result) )) { return result; } else { throw new Error(`Output adapters may only return a plain object from their parse method (or nothing at all)`); } } } else { throw unreachable(`Encountered expectation for unexpected channel '${channelName}'`); } } const NoResult = Symbol("NoResult"); function testExpectations(expectations, channels) { return expectations .map((expectation) => { try { return tryExpectation(expectation, channels); } catch (error) { if (error instanceof textParser.ParseError) { if (expectation.required !== true) { return NoResult; } else { let channelName = expectation.channel; throw chain(error, errors.ExpectedOutputMissing, `A required parser failed to parse the output on ${channelName}`, { failedChannel: channelName }); } } else { throw error; } } }) .filter((result) => result !== NoResult); } // FIXME: Immutable-builder abstraction // FIXME: validatem // FIXME: Reconsider the exit code handling; should we always permit stderr parsing even if a non-zero exit code occurs? module.exports = function createBinaryInvocation(command, args = []) { /* FIXME: The below disallows dashes in the args, but not in the command. Is that what we want? */ validateArguments(args); return { _settings: { asRoot: false, expectations: [], flags: {}, environment: {}, expectedExitCodes: [0], resultRequired: false, resultMerger: function (results) { return results.reduce((merged, result) => Object.assign(merged, result), {}); } }, _withSettings: function (newSettings) { let newObject = Object.assign({}, this, { _settings: Object.assign({}, this._settings, newSettings) }); return newObject; }, _withExpectation: function (expectation) { return this._withSettings({ expectations: this._settings.expectations.concat([ expectation ]) }); }, asRoot: function () { return this._withSettings({ asRoot: true }); }, withAllowedExitCodes: function (allowedExitCodes) { return this._withSettings({ expectedExitCodes: allowedExitCodes }); }, withAnyExitCode: function () { return this._withSettings({ expectedExitCodes: null }); }, withFlags: function (flags) { if (flags != null) { return this._withSettings({ flags: Object.assign({}, this._settings.flags, flags) }); } else { return this; } }, withEnvironment: function (environment) { if (environment != null) { return this._withSettings({ environment: Object.assign({}, this._settings.environment, environment) }); } else { return this; } }, withModifier: function (modifierFunction) { if (modifierFunction != null) { return modifierFunction(this); } else { return this; } }, expectOnStdout: function (adapter) { return this._withExpectation({ channel: "stdout", adapter: adapter }); }, requireOnStdout: function (adapter) { return this._withExpectation({ channel: "stdout", adapter: adapter, required: true }); }, // failOnStdout: function (adapter) { // return this._withExpectation({ // channel: "stdout", // adapter: adapter, // disallowed: true // }); // }, failOnAnyStdout: function () { return this._withExpectation({ channel: "stdout", adapter: OutputForbidden, disallowed: true }); }, expectOnStderr: function (adapter) { return this._withExpectation({ channel: "stderr", adapter: adapter }); }, requireOnStderr: function (adapter) { return this._withExpectation({ channel: "stderr", adapter: adapter, required: true }); }, // failOnStderr: function (adapter) { // return this._withExpectation({ // channel: "stderr", // adapter: adapter, // disallowed: true // }); // }, failOnAnyStderr: function () { return this._withExpectation({ channel: "stderr", adapter: OutputForbidden, disallowed: true }); }, requireResult: function () { // NOTE: This requires that *any* adapter produces a result, it doesn't matter which one. // FIXME: Should this be inverted so that "requires result" is the default, and the user can opt out of that? return this._withSettings({ requireResult: true }); }, then: function () { throw new Error("Attempted to use a command builder as a Promise; you probably forgot to call .execute"); }, execute: function () { return Promise.try(() => { let effectiveCommand = command; let effectiveArgs = flagsToArgs(this._settings.flags).concat(args); if (this._settings.asRoot) { effectiveCommand = "sudo"; effectiveArgs = [command].concat(effectiveArgs); } // FIXME: Shouldn't we represent this in its original form, or at least an escaped form? And suffix 'Unsafe' to ensure it's not used in any actual execution code. let effectiveCompleteCommand = [effectiveCommand].concat(effectiveArgs); return Promise.try(() => { debug(`Running: ${effectiveCommand} ${effectiveArgs.map((arg) => `"${arg}"`).join(" ")}`); return execFileAsync(effectiveCommand, effectiveArgs, { env: Object.assign({}, process.env, this._settings.environment) }); }).then(({stdout, stderr}) => { return { stdout, stderr, exitCode: 0 }; }).catch((error) => { let {stdout, stderr} = error; let exitCode = (typeof error.code === "number") ? error.code : null; return { stdout, stderr, error, exitCode }; }).then(({stdout, stderr, error, exitCode}) => { let { expectedExitCodes, expectations, resultMerger, resultRequired } = this._settings; let expectationsByChannel = splitFilterN(expectations, [ "stdout", "stderr" ], (expectation) => expectation.channel); let channels = { stdout, stderr }; // 1. process stderr expectations // 2. throw on invalid exit code if there was no stderr match // 3. only process stdout expectations if exit code was valid *and* there was no throw try { let hasValidExitCode = testExitCode(exitCode, expectedExitCodes); let stderrResults = testExpectations(expectationsByChannel.stderr, channels); // TODO: Add an option to validate the exit code *even* when there's stderr output if (stderrResults.length === 0 && !hasValidExitCode) { // FIXME: Can we actually pass `error` to be chained onto here, when there's a case where `error` is undefined? Namely, when requiring a non-zero exit code, but the process exits with 0. throw chain(error, errors.NonZeroExitCode, `Expected exit code to be one of ${JSON.stringify(expectedExitCodes)}, but got '${exitCode}'`, { exitCode: exitCode, stdout: stdout, stderr: stderr }); } let stdoutResults = testExpectations(expectationsByChannel.stdout, channels); let allResults = concatArrays(stderrResults, stdoutResults); let mergedResults = asExpression(() => { if (allResults.length === 0) { if (!resultRequired) { return {}; } else { throw new errors.ExpectedOutputMissing(`At least one of the output parsers should have produced a result, but none of them did`); } } else if (allResults.length === 1) { return allResults[0]; } else { // FIXME: Make merger explicitly configurable with a dedicated configuration method return resultMerger(allResults); } }); return { exitCode: exitCode, stdout: stdout, stderr: stderr, result: mergedResults }; } catch (error) { // FIXME: Use getAllContext let message = (error.failedChannel != null) ? `Failed while processing ${error.failedChannel} of command` : "Failed while processing result of command execution"; throw chain(error, errors.CommandExecutionFailed, message, { exitCode: exitCode, stdout: stdout, stderr: stderr }); } }).catch(rethrowAs(errors.CommandExecutionFailed, `An error occurred while executing '${command}'`, { command: effectiveCompleteCommand })); }); } }; };