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.

287 lines
8.9 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 { rethrowAs, chain } = require("error-chain");
const textParser = require("../text-parser");
const errors = require("./errors");
/* 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 */
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");
}
}
// FIXME: Immutable-builder abstraction
// FIXME: validatem
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],
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 });
},
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
});
},
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: null,
disallowed: 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}) => {
try {
let channels = { stdout, stderr };
if (!this._settings.expectedExitCodes.includes(exitCode)) {
// 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(this._settings.expectedExitCodes)}, but got '${exitCode}'`, {
exitCode: exitCode,
stdout: stdout,
stderr: stderr
});
} else {
let expectationResults = this._settings.expectations
.map((expectation) => {
if (expectation.adapter == null) {
if (channels[expectation.channel] != null) {
if (channels[expectation.channel].length > 0) {
throw new errors.UnexpectedOutput(`Encountered output on '${expectation.channel}', but no output was supposed to be produced there`, {
failedChannel: expectation.channel
});
} else {
return undefined;
}
} else {
// FIXME: use @joepie91/unreachable
throw new Error(`Encountered expectation for unexpected channel '${expectation.channel}'; this is a bug, please report it`, {
failedChannel: expectation.channel
});
}
} else {
let result = asExpression(() => {
try {
return expectation.adapter.parse(channels[expectation.channel].toString());
} catch (error) {
// TODO: What if both `required` *and* `disallowed`? Can that ever occur, conceptually speaking?
if (error instanceof textParser.NoResult) {
// FIXME: Annotate to make error source clearer?
if (expectation.required === true) {
throw error;
} else {
return undefined;
}
} else {
throw chain(error, errors.OutputParsingFailed, `An error occurred while parsing '${expectation.channel}'`, {
failedChannel: expectation.channel
});
}
}
});
if (result !== undefined && (typeof result !== "object" || Array.isArray(result))) {
throw new Error(`Output adapters may only return a plain object from their parse method (or nothing at all)`);
} else if (result !== undefined && expectation.disallowed === true) {
// TODO: How to make this error more informative?
throw new errors.UnexpectedOutput(`Encountered output on '${expectation.channel}' that isn't supposed to be there`, {
failedChannel: expectation.channel
});
} else {
return result;
}
}
})
.filter((result) => {
return (result != null);
});
let mergedResults = (expectationResults.length > 0)
? this._settings.resultMerger(expectationResults)
: expectationResults[0];
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
}));
});
}
};
};