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