"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 { rethrowAs, chain } = require("error-chain"); const textParser = require("../text-parser"); const errors = require("./errors"); /* 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 */ 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"); } } // FIXME: Immutable-builder abstraction // FIXME: validatem 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], 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 }); }, 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 }); }, 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: null, disallowed: 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}) => { try { let channels = { stdout, stderr }; if (!this._settings.expectedExitCodes.includes(exitCode)) { // 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(this._settings.expectedExitCodes)}, but got '${exitCode}'`, { exitCode: exitCode, stdout: stdout, stderr: stderr }); } else { let expectationResults = this._settings.expectations .map((expectation) => { if (expectation.adapter == null) { if (channels[expectation.channel] != null) { if (channels[expectation.channel].length > 0) { throw new errors.UnexpectedOutput(`Encountered output on '${expectation.channel}', but no output was supposed to be produced there`, { failedChannel: expectation.channel }); } else { return undefined; } } else { // FIXME: use @joepie91/unreachable throw new Error(`Encountered expectation for unexpected channel '${expectation.channel}'; this is a bug, please report it`, { failedChannel: expectation.channel }); } } else { let result = asExpression(() => { try { return expectation.adapter.parse(channels[expectation.channel].toString()); } catch (error) { // TODO: What if both `required` *and* `disallowed`? Can that ever occur, conceptually speaking? if (error instanceof textParser.NoResult) { // FIXME: Annotate to make error source clearer? if (expectation.required === true) { throw error; } else { return undefined; } } else { throw chain(error, errors.OutputParsingFailed, `An error occurred while parsing '${expectation.channel}'`, { failedChannel: expectation.channel }); } } }); if (result !== undefined && (typeof result !== "object" || Array.isArray(result))) { throw new Error(`Output adapters may only return a plain object from their parse method (or nothing at all)`); } else if (result !== undefined && expectation.disallowed === true) { // TODO: How to make this error more informative? throw new errors.UnexpectedOutput(`Encountered output on '${expectation.channel}' that isn't supposed to be there`, { failedChannel: expectation.channel }); } else { return result; } } }) .filter((result) => { return (result != null); }); let mergedResults = (expectationResults.length > 0) ? this._settings.resultMerger(expectationResults) : expectationResults[0]; 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 })); }); } }; };