You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
cvm/src/exec-binary.js

375 lines
11 KiB
JavaScript

"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
}));
});
}
};
};