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

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