"use strict"; require("array.prototype.flat").shim(); const Promise = require("bluebird"); const util = require("util"); const execFileAsync = util.promisify(require("child_process").execFile); const execAll = require("execall"); const debug = require("debug")("cvm:execBinary"); const errors = require("./errors"); let None = Symbol("None"); /* FIXME: How to handle partial result parsing when an error is encountered in the parsing code? */ /* FIXME: "terminal" flag for individual matches in exec-binary */ /* 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 regexExpectationsForChannel(object, channel) { return object._settings.expectations.filter((expectation) => { return expectation.channel === channel && expectation.type === "regex"; }); } function executeExpectation(expectation, stdout, stderr) { let output = (expectation.channel === "stdout") ? stdout : stderr; if (expectation.type === "regex") { if (expectation.regex.test(output)) { return executeRegexExpectation(expectation, output); } else { return None; } } else if (expectation.type === "json") { let parsedOutput = JSON.parse(output); if (expectation.callback != null) { return expectation.callback(parsedOutput); } else { return parsedOutput; } } else { throw new Error(`Unexpected expectation type: ${expectation.type}`); } } function executeRegexExpectation(expectation, input) { function processResult(fullMatch, groups) { if (expectation.callback != null) { return expectation.callback(groups, fullMatch, input); } else { return groups; } } if (expectation.matchAll) { let matches = execAll(expectation.regex, input); if (matches.length > 0) { /* FILEBUG: File issue on execall repo to document the no-match output */ let results = matches.map((match) => { return processResult(match.match, match.sub); }).filter((result) => { return (result !== None); }); if (results.length > 0) { return results; } else { return None; } } else { return None; } } else { let match = expectation.regex.exec(input); if (match != null) { return processResult(match[0], match.slice(1)); } else { return None; } } } function verifyRegex(regex, {matchAll}) { if (matchAll === true && !regex.flags.includes("g")) { throw new Error("You enabled the 'matchAll' option, but the specified regular expression is not a global one; you probably forgot to specify the 'g' flag"); } } 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"); } } 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, singleResult: false, atLeastOneResult: false, jsonStdout: false, jsonStderr: false, expectations: [], flags: {}, environment: {} }, _withSettings: function (newSettings) { let newObject = Object.assign({}, this, { _settings: Object.assign({}, this._settings, newSettings) }); /* FIXME: Make this ignore json expectations */ let hasStdoutExpectations = (regexExpectationsForChannel(newObject, "stdout").length > 0); let hasStderrExpectations = (regexExpectationsForChannel(newObject, "stderr").length > 0); if (newObject._settings.jsonStdout && hasStdoutExpectations) { throw new Error("The 'expectJsonStdout' and 'expectStdout' options cannot be combined"); } else if (newObject._settings.jsonStderr && hasStderrExpectations) { throw new Error("The 'expectJsonStderr' and 'expectStderr' options cannot be combined"); } else { return newObject; } }, asRoot: function () { return this._withSettings({ asRoot: true }); }, singleResult: function () { return this._withSettings({ singleResult: true }); }, atLeastOneResult: function () { return this._withSettings({ atLeastOneResult: true }); }, /* NOTE: Subsequent withFlags calls involving the same flag key will *override* the earlier value, not add to it! */ 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; } }, expectJsonStdout: function (callback) { if (!this._settings.jsonStdout) { return this._withSettings({ jsonStdout: true, expectations: this._settings.expectations.concat([{ type: "json", channel: "stdout", key: "stdout", callback: callback }]) }); } }, expectJsonStderr: function (callback) { if (!this._settings.jsonStderr) { return this._withSettings({ jsonStderr: true, expectations: this._settings.expectations.concat([{ type: "json", channel: "stderr", key: "stderr", callback: callback }]) }); } }, expectStdout: function (key, regex, {required, result, matchAll} = {}) { verifyRegex(regex, {matchAll}); return this._withSettings({ expectations: this._settings.expectations.concat([{ type: "regex", channel: "stdout", required: (required === true), key: key, regex: regex, callback: result, matchAll: matchAll }]) }); }, expectStderr: function (key, regex, {required, result, matchAll} = {}) { verifyRegex(regex, {matchAll}); return this._withSettings({ expectations: this._settings.expectations.concat([{ type: "regex", channel: "stderr", required: (required === true), key: key, regex: regex, callback: result, matchAll: matchAll }]) }); }, 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); } 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 finalResult, resultFound; try { if (this._settings.singleResult) { let result = None; let i = 0; while (result === None && i < this._settings.expectations.length) { let expectation = this._settings.expectations[i]; result = executeExpectation(expectation, stdout, stderr); if (expectation.required === true && result === None) { throw new errors.ExpectedOutputMissing(`Expected output not found for key '${expectation.key}'`, { exitCode: exitCode, stdout: stdout, stderr: stderr }); } i += 1; } finalResult = result; resultFound = (finalResult !== None); } else { let results = this._settings.expectations.map((expectation) => { let result = executeExpectation(expectation, stdout, stderr); if (result === None) { if (expectation.required === true) { throw new errors.ExpectedOutputMissing(`Expected output not found for key '${expectation.key}'`, { exitCode: exitCode, stdout: stdout, stderr: stderr }); } else { return result; } } else { return { key: expectation.key, value: result }; } }).filter((result) => { return (result !== None); }); resultFound = (results.length > 0); finalResult = results.reduce((object, {key, value}) => { return Object.assign(object, { [key]: value }); }, {}); } } catch (processingError) { throw errors.UnexpectedOutput.chain(processingError, "An error occurred while processing command output", { command: effectiveCompleteCommand, exitCode: exitCode, stdout: stdout, stderr: stderr }); } if (resultFound || this._settings.atLeastOneResult === false) { if (error != null) { throw new errors.NonZeroExitCode.chain(error, `Process '${command}' exited with code ${exitCode}`, { exitCode: exitCode, stdout: stdout, stderr: stderr, result: finalResult }); } else { return { exitCode: exitCode, stdout: stdout, stderr: stderr, result: finalResult }; } } else { throw new errors.ExpectedOutputMissing("None of the expected outputs for the command were encountered, but at least one result is required", { exitCode: exitCode, stdout: stdout, stderr: stderr }); } }).catch(errors.CommandExecutionFailed.rethrowChained(`An error occurred while executing '${command}'`, { command: effectiveCompleteCommand })); }); } }; };