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.
375 lines
11 KiB
JavaScript
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
|
|
}));
|
|
});
|
|
}
|
|
};
|
|
}; |