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